Modern CSS

Chapter 10 — Modern CSS Features

The CSS that shipped between 2022 and 2025 is the most significant upgrade to the language since Flexbox. Cascade layers, native nesting, @scope, logical properties, and content-visibility each solve problems that previously required either preprocessors, naming conventions, or JavaScript. This chapter covers the features that are either baseline-available today or shipping in all evergreen browsers right now.

1. Cascade Layers — @layer

The cascade has always resolved conflicts by specificity and source order. That works fine until a third-party library uses high-specificity selectors that override your styles, or until your utility classes need to beat your component styles without resorting to !important. Cascade layers add an explicit priority tier above specificity — styles in a higher-priority layer always win, regardless of selector weight.

Layer priority order (highest wins)

unlayered styles
← highest priority (always wins)
@layer override
utilities, one-offs
@layer theme
brand tokens applied to components
@layer components
card, button, modal…
@layer base
resets, element defaults
@layer vendor
← lowest priority
Key insight. The layer declared last in the order statement wins. Unlayered styles (no @layer at all) always beat all layered styles — so existing code without layers automatically takes priority over anything you put in a layer. This makes layered adoption safe: old code isn't broken by new layers.
/* Step 1: Declare the order. Layer declared LAST wins. */ @layer vendor, base, components, theme, override; /* Step 2: Assign styles to layers */ @layer vendor { /* Third-party CSS — sandboxed, can't leak out */ .btn { background: blue; padding: 8px 16px; } } @layer base { /* Reset + element defaults */ *, *::before, *::after { box-sizing: border-box; } body { margin: 0; font-family: system-ui, sans-serif; } } @layer components { /* High-specificity selectors here DON'T beat 'override' layer */ .card .card__header .card__title { font-size: 1.25rem; color: inherit; } } @layer override { /* Low-specificity utilities — but in the highest layer, so they win */ .text-sm { font-size: 0.875rem; } .text-red { color: #ff7b72; } .mt-4 { margin-top: 1rem; } } /* Layers can be spread across multiple files and still honour the order */ @import url('reset.css') layer(base); @import url('bootstrap.min.css') layer(vendor); /* Nested layers — creates a sub-hierarchy */ @layer components { @layer base, state; @layer base { .btn { background: var(--color-primary); } } @layer state { .btn:hover { background: var(--color-primary-hover); } } } /* Reference as components.base and components.state */
!important flips the layer order. Inside !important declarations, the cascade layer priority is reversed — the lowest-priority layer's !important beats all higher layers. This mirrors how browser user stylesheets work. In practice: avoid !important inside layers entirely — that's what layers are for.

2. CSS Nesting

Native CSS nesting landed in all evergreen browsers in 2023. It eliminates the main reason developers reached for Sass or Less — the ability to write parent-child selectors without repeating the parent selector name.

Before — flat CSS (or Sass)
.card { background: #161b22; padding: 1rem; } .card:hover { box-shadow: 0 4px 12px ...; } .card .card__title { font-size: 1.25rem; } .card .card__title a { color: inherit; } .card--featured { border: 1px solid #58a6ff; } /* Repetition. Scroll to find related rules. */
After — native nesting
.card { background: #161b22; padding: 1rem; &:hover { box-shadow: 0 4px 12px ...; } .card__title { font-size: 1.25rem; a { color: inherit; } } &.card--featured { border: 1px solid #58a6ff; } }
/* The & selector — explicit parent reference */ .btn { background: var(--color-primary); /* & = .btn — for pseudo-classes, pseudo-elements, modifiers */ &:hover { background: var(--color-primary-dark); } &:focus-visible { outline: 2px solid currentColor; } &::before { content: ''; } &--large { padding: 0.75rem 1.5rem; } /* .btn--large */ &[disabled] { opacity: 0.4; } /* Without & — the nested selector is a descendant */ .icon { width: 1em; } /* .btn .icon */ /* & can appear anywhere — even at the end */ .dark-mode & { background: #0d1117; } /* .dark-mode .btn */ /* @rules nest too */ @media (max-width: 768px) { width: 100%; } @layer state { &.is-loading { cursor: wait; } } } /* :is() for complex nesting without specificity accumulation */ .prose { :is(h1, h2, h3, h4) { margin-top: 1.5em; line-height: 1.3; } }
Nesting and element selectors. A bare element selector inside a nest (e.g. p { color: red; }) is treated as a descendant, not a direct child. Element selectors in nests were initially problematic in early implementations — all current browsers handle them correctly, but if you need to be safe, wrap in :is(p) { } or use & p { }.

3. @scope — Scoped Styles

@scope solves the same problem as BEM naming, CSS Modules, and Shadow DOM style encapsulation — preventing styles from leaking out of a component. Unlike those solutions, @scope is pure CSS with no tooling or build step, and it uses proximity rather than specificity to resolve conflicts.

/* @scope(root) to(limit) — styles apply only inside root, not inside limit */ @scope (.card) to (.card__body) { /* applies inside .card, but NOT inside .card__body */ .title { font-size: 1.25rem; } } /* Proximity wins over specificity for competing @scope rules */ @scope (.light-theme) { p { color: #1a1a1a; } } @scope (.dark-theme) { p { color: #c9d1d9; } } /* <div class="dark-theme"><p> → dark wins because it's closer */ /* <div class="dark-theme"><div class="light-theme"><p> → light wins */ /* Inside <style scoped> — an inline scope targeting its own element */ /* <style> in a component template */ @scope { /* :scope = the element that owns this style block */ :scope { border: 1px solid #30363d; } :scope .title { font-weight: bold; } } /* Donut scope — apply between root and limit */ @scope (.media-widget) to (.ad-slot, .third-party) { /* styles apply in .media-widget but stop at .ad-slot */ /* third-party content inside .ad-slot is safely excluded */ img { max-width: 100%; } }

4. :has() in Layout Contexts

:has() was introduced in Chapter 4 as a selector feature. Here we look at its role in larger layout patterns — replacing JavaScript that previously watched the DOM for conditional layout changes.

/* Layout switch based on content type */ .card:has(img) { display: grid; grid-template-columns: 180px 1fr; } .card:not(:has(img)) { display: block; } /* Body layout based on open sidebar */ body:has(.sidebar[aria-expanded="true"]) { grid-template-columns: 260px 1fr; } body:has(.sidebar[aria-expanded="false"]) { grid-template-columns: 60px 1fr; } /* Dialog/modal open — page scroll lock without JS */ html:has(dialog[open]) { overflow: hidden; } /* Form validation state — highlight the label when input is invalid */ .form-group:has(input:invalid:not(:placeholder-shown)) { label { color: #ff7b72; } input { border-color: #ff7b72; } .hint { display: block; } } /* Previous sibling — what :has() makes possible */ .item:has(+ .item--highlight) { opacity: 0.5; /* dim the item BEFORE a highlighted item */ } /* Count-based layout — no JavaScript */ .grid:has(.item:nth-child(4)) { grid-template-columns: repeat(2, 1fr); } .grid:has(.item:nth-child(7)) { grid-template-columns: repeat(3, 1fr); } .grid:has(.item:nth-child(10)) { grid-template-columns: repeat(4, 1fr); }

5. Logical Properties

Physical properties (margin-left, padding-top) are tied to screen directions. Logical properties (margin-inline-start, padding-block-start) map to the writing mode — so your layout automatically mirrors for right-to-left languages (Arabic, Hebrew) and vertical writing systems (Japanese, Mongolian) without any direction-specific overrides.

Physical → Logical mapping

margin-top
margin-block-start
Block axis start
margin-bottom
margin-block-end
Block axis end
margin-left
margin-inline-start
Inline axis start
margin-right
margin-inline-end
Inline axis end
padding-top / bottom
padding-block
Both block edges
padding-left / right
padding-inline
Both inline edges
width
inline-size
Size along inline axis
height
block-size
Size along block axis
top / bottom / left / right
inset-block / inset-inline
Inset shorthands
border-top
border-block-start
Block start border
border-left
border-inline-start
Inline start border
text-align: left
text-align: start
Start of inline direction
/* Block = vertical direction (in horizontal writing) */ /* Inline = horizontal direction (in horizontal writing) */ /* Component that works in LTR, RTL, and vertical writing — same code */ .card { padding-block: 1.5rem; /* top and bottom in LTR */ padding-inline: 1.25rem; /* left and right in LTR */ border-inline-start: 3px solid var(--color-accent); /* left border in LTR, right border in RTL — automatically */ } .icon-badge { margin-inline-end: 0.5rem; /* right margin in LTR, left margin in RTL */ } /* max-inline-size replaces max-width for flow-aware sizing */ .prose { max-inline-size: 65ch; margin-inline: auto; /* centres horizontally in LTR */ } /* Vertical centring with logical properties */ .overlay { position: absolute; inset: 0; /* shorthand for top/right/bottom/left: 0 */ inset-block-start: 1rem; /* override just the top */ } /* Writing mode — vertical Japanese layout */ .japanese-novel { writing-mode: vertical-rl; /* block axis is now horizontal, inline is vertical */ /* margin-block-start = margin-right, padding-inline = padding top/bottom */ }
When to use logical properties. Use them by default in new code — the overhead is zero and the i18n benefit is free. The only exception is when you genuinely mean a physical direction (e.g. a fixed header at the top of the screen should use top: 0, not inset-block-start: 0, since it's physically anchored regardless of writing mode).

6. content-visibility

content-visibility: auto lets the browser skip rendering off-screen content entirely — style, layout, and paint are skipped until the element enters the viewport. On long pages (dashboards, feeds, long-form articles), this can cut initial render time by 50% or more with a single CSS line.

/* content-visibility: auto — skip rendering off-screen sections */ section { content-visibility: auto; contain-intrinsic-size: auto 800px; /* Browser reserves ~800px height for off-screen sections */ /* Prevents scroll jumps as content renders in */ /* 'auto' prefix means it uses the measured height after first render */ } /* content-visibility: hidden — like display: none but preserves state */ .offscreen-panel { content-visibility: hidden; /* Not rendered, not in accessibility tree, but state preserved */ /* Better than display: none for pre-rendered panels (dialogs, drawers) */ /* Switch to content-visibility: visible to show */ } /* contain — the underlying isolation primitive */ .widget { contain: layout style paint; /* layout: changes inside don't affect outside layout */ /* style: counters and quotes don't escape this element */ /* paint: children don't paint outside the border box */ /* size: element size doesn't depend on children — needs explicit size */ /* content = layout + style + paint (most useful shorthand) */ /* strict = layout + style + paint + size */ } /* Practical: long comment thread or infinite scroll feed */ .comment { content-visibility: auto; contain-intrinsic-size: auto 120px; } /* A 1,000-comment page renders only the visible viewport. Off-screen comments are skipped until scroll brings them in. */
Gotcha — find-in-page and accessibility. With content-visibility: auto, off-screen elements are not in the accessibility tree and Ctrl+F may not find text inside them until they're rendered. This is acceptable for media feeds but inappropriate for legal text, articles, or anything users would reasonably search. Use hidden only for content that is intentionally not presented (pre-rendered panels).

7. Baseline 2024–2025 — What Just Shipped

Feature Status What it does
CSS nesting Baseline 2023 Native & parent selector, nested @rules. All evergreen browsers.
@layer Baseline 2022 Explicit cascade priority tiers. Full cross-browser support.
:has() Baseline 2023 Parent selector. All evergreen browsers since late 2023.
container queries Baseline 2023 @container, cqw/cqh units. Full support.
color-mix() Baseline 2023 Mix two colours in any colour space. Full support.
oklch() / oklab() Baseline 2023 Perceptual colour spaces. Full support.
scroll-driven animations Baseline 2024 animation-timeline: scroll() / view(). Chrome + Firefox + Safari 18.
@property Baseline 2024 Typed custom properties. All evergreen as of mid-2024.
light-dark() Baseline 2024 Two-value colour-scheme helper. Full support 2024.
transition-behavior: allow-discrete Baseline 2024 Animate display, visibility, overlay. Full support 2024.
@starting-style Baseline 2024 Define enter-animation start state. Full support 2024.
popover API + ::backdrop Baseline 2024 HTML popover attribute with CSS ::backdrop animations.
@scope Baseline 2024 Scoped styles with proximity. Chrome, Firefox, Safari — all 2024.
anchor positioning Emerging 2024 Position an element relative to another (tooltips, dropdowns) — anchor-name / anchor() / position-area. Chrome 125+; Safari and Firefox flags only.
view transitions Emerging 2024 document.startViewTransition() + ::view-transition CSS. Cross-document transitions in Chrome 126+.
if() in CSS Proposed 2025 Inline conditional: color: if(style(--variant: dark): white; else: black). No browser support yet.
CSS mixins / @apply revival Proposed 2025 Reusable rule blocks without duplication. Early spec work only.

Anchor positioning — the future of tooltips

/* Anchor positioning — position an element relative to another DOM element */ /* No JS, no getBoundingClientRect, no Popper.js */ .trigger { anchor-name: --my-anchor; } .tooltip { position: absolute; position-anchor: --my-anchor; position-area: top center; /* above the anchor, centred */ margin-block-end: 8px; /* position-try-fallbacks — flip if no room */ position-try-fallbacks: flip-block, flip-inline, flip-start; }

View transitions — page-change animations

/* Same-page view transitions — animate between two states */ button.addEventListener('click', () => { document.startViewTransition(() => { updateTheDOMSomehow(); }); }); /* CSS controls the animation */ ::view-transition-old(root) { animation: fade-out 0.3s ease; } ::view-transition-new(root) { animation: fade-in 0.3s ease; } /* Named transitions for individual elements */ .hero-image { view-transition-name: hero; /* browser morphs this between pages */ } ::view-transition-group(hero) { animation-duration: 0.5s; animation-timing-function: ease-in-out; }

Chapter Summary

FeatureKey point
@layer orderLayers declared last win. Declare the order once at the top: @layer vendor, base, components, theme, override. Unlayered styles always beat all layers.
@layer + !important!important reverses the layer order — the lowest-priority layer's !important beats higher layers. Avoid !important inside layers entirely.
Nesting && is explicit parent reference. Without &, nested selectors are descendants. & can appear anywhere in the selector — .dark & targets .dark .btn. @media and @layer can be nested inside rules.
@scope proximityWhen two @scope rules compete, the closer ancestor wins — regardless of specificity. Donut scope (root) to (limit) excludes content inside limit subtrees.
:has() layouthtml:has(dialog[open]) { overflow: hidden } replaces a common JS pattern. body:has(.sidebar[open]) can switch the entire grid template. No JS needed.
Logical propertiesUse block/inline instead of top/bottom and left/right. margin-inline: auto centres. border-inline-start is the "left" border that flips for RTL automatically.
inset shorthandinset: 0 is shorthand for top: 0; right: 0; bottom: 0; left: 0. inset-block and inset-inline are the logical variants.
content-visibilitycontent-visibility: auto skips rendering off-screen elements. Always pair with contain-intrinsic-size: auto Npx to reserve space and prevent scroll jumps.
Baseline 2024Fully baseline in all evergreens: @property, light-dark(), allow-discrete, @starting-style, @scope, scroll-driven animations, popover API.
anchor positioninganchor-name on the trigger + position-anchor on the tooltip. position-area replaces manual offset calculations. position-try-fallbacks for auto-flip. Chrome 125+ only as of 2025.
Exercises
  1. Layer your stylesheet: Take an existing stylesheet (or create a small one) and split it into @layer base, components, and utilities. Declare the order at the top. Verify that a low-specificity utility class in the utilities layer can override a high-specificity rule in the components layer without !important.
  2. Nest a component: Convert a BEM-style flat stylesheet for a .nav component (with .nav__item, .nav__link, .nav__link--active, .nav__link:hover) into native CSS nesting. Add a nested @media rule for mobile. Verify the compiled output in DevTools matches the flat version.
  3. :has() form validation: Build a form with three inputs. Using only CSS and :has(), make the submit button go green when all three inputs are :valid, and display inline error messages (hidden by default) when any input is :invalid:not(:placeholder-shown). No JavaScript.
  4. Logical properties RTL test: Build a card with padding-inline, margin-inline-end on an icon, and a border-inline-start accent. Add dir="rtl" to the card's parent. Confirm the layout mirrors correctly — the border moves to the right, the icon margin flips.
  5. content-visibility performance: Build a page with 100 identical article cards. Open DevTools Performance, record a page load without content-visibility. Add content-visibility: auto; contain-intrinsic-size: auto 200px to each card. Record again and compare the Rendering and Painting timing.
Next: Chapter 11 — CSS Architecture. Naming conventions (BEM, CUBE CSS, Utility-first), design token organisation, CSS custom property strategies at scale, layer strategy for large teams, and how to evaluate whether a methodology fits your project.