Guide
CSS nesting explained
Harbor Commerce's product catalog stylesheet repeated
.product-card on 47 descendant selectors — every hover,
focus, and sale-badge variant duplicated the block prefix. Engineers wanted
Sass-style colocation without reintroducing a preprocessor pipeline on a
static site that already compiled
Tailwind
from src/input.css. Native CSS nesting (Baseline
2023, all evergreen browsers) let them nest modifiers inside
.product-card { } blocks using the & parent
selector, cut duplicated tokens by 41%, and keep specificity flat at 0,1,0
per modifier. CSS nesting is a language feature that lets you write descendant
and compound selectors inside a parent rule block, mirroring DOM structure
without repeating class prefixes. This guide covers implicit vs explicit
nesting, & patterns, specificity and
@layer
interaction, Sass migration traps, the Harbor Commerce refactor, a technique
decision table vs BEM and CSS Modules, pitfalls, and a production checklist
alongside our
CSS fundamentals guide.
Native nesting syntax
Before nesting, every descendant selector repeated its ancestors:
.product-card { padding: var(--space-4); }
.product-card:hover { box-shadow: var(--shadow-md); }
.product-card .badge { font-size: 0.75rem; }
.product-card .badge--sale { color: var(--color-sale); }
With nesting, child rules live inside the parent block:
.product-card {
padding: var(--space-4);
&:hover {
box-shadow: var(--shadow-md);
}
.badge {
font-size: 0.75rem;
&--sale {
color: var(--color-sale);
}
}
}
The browser desugars this to the flat selectors above. Nesting is not a preprocessor trick — it is part of the CSS cascade and participates in specificity exactly as if you had written the expanded form.
Implicit vs explicit nesting
A nested rule that starts with a combinator or type selector nests implicitly:
.card {
h2 { font-size: 1.25rem; } /* .card h2 */
> img { border-radius: 8px; } /* .card > img */
}
A nested rule that starts with a class, ID, or pseudo-class must use
& to attach to the parent; otherwise the parser treats it as an
invalid or ambiguous relative selector:
.card {
&.is-featured { border: 2px solid gold; } /* .card.is-featured */
&:focus-within { outline: 2px solid blue; } /* .card:focus-within */
}
The & token represents the full parent selector list. In
.a, .b { &:hover { } } the expansion is
.a:hover, .b:hover — one ampersand per parent compound.
Specificity, layers, and :has()
Nesting does not change specificity math: .card .badge is still
0,2,0; .card:hover is 0,2,0 (one class + one pseudo-class).
Deep nesting is a common footgun — three levels of classes yields 0,3,0
and can beat token-level utilities unless you pair nesting with
cascade layers:
@layer components {
.product-card {
padding: var(--space-4);
&:hover { transform: translateY(-2px); }
}
}
@layer utilities {
.u-no-transform { transform: none; }
}
Layer order lets utilities beat nested component hover rules without
!important. Nesting also pairs well with
:has()
for parent-conditional styling colocated with the component:
.checkout-row {
&:has(input:user-invalid) {
border-color: var(--color-error);
}
&:has(.coupon-applied) .total {
color: var(--color-success);
}
}
Keep :has() branches shallow; expensive selectors inside large
lists can hurt style recalculation on low-end mobile.
Sass migration and tooling
Sass nesting is similar but not identical. Key differences when porting:
- Parent reference required — Sass allowed
.child { }inside.parentto mean.parent .child; native CSS requires& .childor implicit combinator starts. - No variables or mixins — use custom properties and
@layerinstead of@mixin. &-suffix— BEM-style&__titlestill works: expands to.block__titlewhen parent is.block.- PostCSS — older pipelines used
postcss-nestingwith slightly different rules; verify against native output before dropping the plugin.
Stylelint 16+ ships stylelint-config-standard rules for native
nesting. Enable nesting-selector-no-missing-scoping-root to catch
forgotten & on class-nested rules.
Harbor Commerce product card refactor (worked example)
Problem. The catalog shipped 312 lines across
product-card.css and product-card-states.css. Sale,
out-of-stock, and featured variants each re-declared
.product-card prefixes; a rename to .catalog-card
required 38 search-replace hits and missed two hover rules in QA.
Change. Merged into one nested file inside
@layer components:
@layer components {
.product-card {
display: grid;
gap: var(--space-3);
border-radius: var(--radius-lg);
background: var(--glass);
&:hover { box-shadow: var(--shadow-md); }
&:focus-within { outline: 2px solid var(--focus-ring); }
&.is-featured { grid-column: span 2; }
&.is-sold-out { opacity: 0.6; pointer-events: none; }
.media { aspect-ratio: 4/3; overflow: hidden; }
.title { font-weight: 600; }
.price { font-variant-numeric: tabular-nums; }
.badge {
&--sale { background: var(--color-sale); }
&--new { background: var(--color-accent); }
}
}
}
Results. Line count fell from 312 to 184 (−41%).
Specificity audit: all component rules stayed at 0,1,0 or 0,2,0; no new
!important. Rename test: changing the root class required one
edit. Stylelint caught two missing & during CI on the first PR.
Lesson. Nesting pays off when one block owns many variants. For atomic utility-first pages, Tailwind classes in markup may still be simpler than nested component CSS.
Technique decision table
| Approach | Colocation | When to choose |
|---|---|---|
| Native CSS nesting | Variants inside component block | Component CSS files, BEM blocks, no Sass pipeline, Baseline 2023+ audience |
| Flat BEM + single class | Separate rules per selector | Maximum grep-ability, legacy browsers, strict stylelint specificity caps |
| CSS cascade layers | Layer stacks, not DOM tree | Vendor sandboxes, utilities vs components override lanes |
| CSS Modules / scoped builds | Per-file hashed classes | React/Vue SPAs, collision-proof class names at scale |
| Utility-first (Tailwind) | Classes in markup | Rapid layout, design tokens in HTML, small component CSS surface |
Nesting and layers compose: nest for readability inside a layer, use layers for conflict resolution across files. Neither replaces responsive breakpoints — nest media queries inside the component block for the same colocation benefit.
Common pitfalls
- Specificity creep — four nested classes beat most utilities; cap depth at two levels or use layers.
- Missing
&—.parent { .child { } }is invalid in native CSS; use& .childor start with a combinator. - Over-nesting type selectors —
.article { p { } }couples styles to markup; prefer classes for reusable components. - Assuming Sass parity —
@extend, parent selectors in mixins, and interpolation do not exist natively. - Bundle size myths — nesting does not shrink deployed CSS versus flat selectors (same expanded output); savings are developer ergonomics and fewer rename bugs.
- Keyframe and @font-face — these at-rules cannot be nested inside style rules; keep them top-level.
Production checklist
- Confirm Baseline 2023 browser support matches your analytics; provide flat CSS fallback only if you still serve IE11 (rare in 2026).
- Place nested component files inside
@layer componentswhen using a layered design system. - Run Stylelint with native nesting rules; block PRs on missing
&for class-first nested selectors. - Audit expanded selector specificity after nesting refactors; target 0,1,0–0,2,0 for component modifiers.
- Limit nesting depth to two levels; extract subcomponents when blocks exceed ~80 lines.
- Pair
:has()nests with performance spot-checks on large DOM lists (product grids, comment threads). - Document
&-suffixBEM convention in CONTRIBUTING if the team uses element/modifier suffixes. - Verify PostCSS nesting plugins are removed or set to "native" mode to avoid double-processing.
- Include one visual regression screenshot per nested component state (hover, focus, disabled) in CI.
- Re-evaluate utility-first vs nested CSS per surface — marketing pages may stay Tailwind-heavy while checkout widgets use nested files.
Key takeaways
- Native CSS nesting colocates variants with their parent block using
&and implicit combinator rules — no Sass required. - Expanded specificity matches flat selectors; deep nesting inflates scores unless layers or utilities contain it.
- Class-first nested rules need
&; Sass-trained teams miss this most often in migration. - Nesting improves maintainability and renames; it does not reduce bytes in the shipped stylesheet.
- Combine nesting with @layer for component readability and predictable override lanes.
Related reading
- CSS cascade layers explained — layer order before specificity
- CSS :has() selector explained — parent-conditional styling inside nested blocks
- CSS fundamentals explained — cascade, specificity, and the box model
- Tailwind CSS fundamentals explained — when utilities beat component CSS