17 — Modern CSS (2023+): Nesting, (), Layers, Style Queries & color-mix
CSS post-2023 replaced JavaScript and preprocessor workarounds with native features. Nesting, :has() (the parent selector), cascade layers, container style queries, color-mix(), and scroll-driven animations are now shipping in all major browsers. This chapter covers the features that change how you architect CSS.
Native Nesting — & is the parent selector
.card {
padding: 1rem;
& .title { font-size: 1.5rem; } /* = .card .title (descendant) */
& p { line-height: 1.6; } /* = .card p */
&:hover { background: #f0f0f0; } /* = .card:hover (compound — needs &) */
&.active { border-color: red; } /* = .card.active */
@media (min-width: 768px) { & { padding: 2rem; } } /* nesting @media inside rules */
}
/* Without &, a nested selector is a DESCENDANT: .title inside .card = .card .title.
Use & for compound selectors (&:hover, &.active, & + .sibling). */
:has() — the relational pseudo-class (parent selector)
/* Style a card based on its DESCENDANTS — the long-requested "parent selector". */
.card:has(img) { padding-block-start: 0; } /* card with an image */
.form-section:has(.error) { border-color: red; } /* section containing an error */
li:has(ul) { font-weight: bold; } /* list item that has a nested list */
.card:not(:has(img)) { padding: 2rem; } /* card WITHOUT an image */
h2:has(+ p) { margin-block-end: 0; } /* heading directly followed by a paragraph */
/* Specificity: :has() adds its ARGUMENT's specificity. .card:has(.error) = .card + .error = (0,2,0). */
Anti-pattern: () rule ordering
/* If the :has() rule comes BEFORE the base rule and specificity is equal, source order may bite.
Put the :has() override AFTER the base for clarity. */
.form-section { border: 2px solid #ccc; } /* base FIRST */
.form-section:has(.error) { border-color: red; } /* override AFTER — clear intent */
Cascade Layers — @layer for precedence control
@layer reset, base, components, utilities; /* order declared FIRST → fixes precedence */
@layer reset { * { box-sizing: border-box; margin: 0; } }
@layer base { body { font-size: 16px; } }
@layer components { .btn { padding: 0.5rem 1rem; } }
@layer utilities { .text-center { text-align: center; } }
/* Layer order sets precedence: utilities (last) beats components beats base, REGARDLESS of specificity.
A .text-center utility (0,0,1,0) beats .btn .text-center (0,0,2,0) because utilities is a later layer. */
/* Unlayered rules beat ALL layered rules — the escape hatch. */
Container Style Queries (2023+)
/* Query a container's custom PROPERTIES (not just size). A card adapts to its container's --theme. */
@container style(--theme: dark) {
.card { background: #222; color: #eee; }
}
/* You can query custom properties, not standard properties (like color). Support: Chrome 111+, Safari 17.4+. */
color-mix() — derive shades at runtime
:root {
--primary: oklch(0.62 0.18 245);
--primary-hover: color-mix(in oklch, var(--primary) 85%, white); /* lighter shade */
--primary-press: color-mix(in oklch, var(--primary) 75%, black); /* darker shade */
/* No preprocessor needed — derive tints/shades from one token, at runtime, cascading. */
}
text-wrap: balance / pretty
h1, h2 { text-wrap: balance; } /* even line lengths for headings — no manual <br> */
p { text-wrap: pretty; } /* avoid a single word on the last line (orphans) */
/* Free typography polish. The browser optimizes wrapping. */
Scroll-Driven Animations (2023+)
@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.progress-bar {
animation: progress linear;
animation-timeline: scroll(root block); /* driven by scroll position, no JS */
transform-origin: inline-start;
}
/* A progress bar that fills as you scroll — pure CSS. Support: Chrome 115+, Safari 17.4+. */
View Transitions API
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; }
document.startViewTransition(() => { /* DOM update — API captures old/new, animates between them */ });
accent-color — theme native form controls
input[type="checkbox"], input[type="radio"], input[type="range"] {
accent-color: var(--primary); /* colors the native control — no custom widget needed */
}
💡 Tips & Tricks
- Idiom:
:has()for state-based styling without JS —.card:has(.error) { border-color: red; }styles a card based on its contents, no JS class toggling. The "parent selector" finally exists. - Idiom:
@layerto control precedence in large codebases —@layer reset, base, components, utilities;makes utilities reliably beat components regardless of specificity. The end of!importantwars. - Idiom: native nesting (
&) for component-scoped styles — reduces repetition, keeps related styles together. Use&for compound selectors (&:hover,&.active); without&it's a descendant. - Idiom:
color-mix(in oklch, var(--c) 80%, white)to derive tints/shades from one token — no preprocessor, cascades, runtime. - Idiom:
text-wrap: balancefor headings,prettyfor paragraphs — free typography polish, no JS or manual<br>.
⚠️ Edge Cases & Gotchas
:has()specificity = its argument's specificity:.card:has(.error)= (0,2,0),.card:has(#x)= (0,1,1,0). A:has()with an ID inside is ID-strength.:has()can't be nested in itself in all engines::has(:has(...))has limited support. Avoid deep nesting.- Cascade layer order is set by the FIRST declaration:
@layer reset, base;fixes the order. Later@layer base { }appends to the existingbasein its position. - Unlayered rules beat layered rules: a rule outside any
@layerwins over all layered rules regardless of specificity. Layer everything or nothing for predictable precedence. - Nesting
&specificity is the combined selector's:& .title=.card .title(0,2,0).&.active=.card.active(0,2,0). - Container style queries query custom properties only:
@container style(--theme: dark)works;@container style(color: red)doesn't. text-wrap: balanceis for short text (headings): on long paragraphs, it can look worse. Useprettyfor paragraphs.- Scroll-driven animations and view transitions are newer (2023+): provide a JS fallback or accept no animation on older browsers.
🧠 Spot the Bug
.form-section:has(.error) { border-color: red; }
.form-section { border: 2px solid #ccc; }
Answer
The :has() rule comes before the base rule. .form-section:has(.error) has specificity (0,2,0) — it beats .form-section (0,1,0) by specificity, so it should win regardless of order. But the base rule uses the border shorthand, which resets border-color to #ccc. If the :has() rule only sets border-color (not the full shorthand), and the base comes after, the shorthand's border-color: #ccc could interfere at equal or higher specificity. The clearer fix: put the base rule first, the :has() override after — source order expresses intent even when specificity would win:
.form-section { border: 2px solid #ccc; } /* base first */
.form-section:has(.error) { border-color: red; } /* override after */
Also verify :has() browser support (Chrome 105+, Safari 15.4+, Firefox 121+) and that .error is actually a descendant of .form-section.