Native CSS Nesting Is Here: Ditch the Preprocessor
Native CSS nesting works in every current browser. The syntax, the `&` rules, and what still needs a preprocessor.
Nesting selectors inside other selectors was the main reason I put up with Sass for years. Browsers handle it natively now, and there's no build step involved.
Basic Syntax
/* Native CSS Nesting */
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
.card-title {
font-size: 1.25rem;
font-weight: 600;
}
.card-body {
color: oklch(0.4 0.02 260);
margin-top: 0.75rem;
}
&:hover {
box-shadow: 0 4px 12px oklch(0 0 0 / 0.1);
}
}Which is the same as writing this:
.card { background: white; border-radius: 8px; padding: 1.5rem; }
.card .card-title { font-size: 1.25rem; font-weight: 600; }
.card .card-body { color: oklch(0.4 0.02 260); margin-top: 0.75rem; }
.card:hover { box-shadow: 0 4px 12px oklch(0 0 0 / 0.1); }The & Selector
& means the parent selector. You need it when you're appending to the parent rather than adding a descendant:
.button {
/* Modifier classes — & is required here */
&.button--primary { background: oklch(0.55 0.22 260); }
&.button--danger { background: oklch(0.55 0.22 25); }
/* Pseudo-classes — & is required */
&:hover { opacity: 0.85; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
/* Pseudo-elements — & is required */
&::before { content: ""; display: block; }
/* Descendant — & is optional (but explicit) */
& .icon { width: 1em; height: 1em; }
.icon { width: 1em; height: 1em; } /* Same result */
}Bare .icon and & .icon compile to the same thing. I still write the second one when I mean a descendant, because it survives being moved out of the block during a refactor.
Media Queries Inside Selectors
@media, @supports and other at-rules nest too:
.sidebar {
width: 280px;
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
flex-direction: column;
}
@supports (display: grid) {
display: grid;
grid-template-rows: auto 1fr auto;
}
}The responsive overrides stay next to the rule they change. In flat CSS the mobile version of a component ends up 400 lines below it.
Nesting and Specificity
A nested selector has the same specificity as the flat version. Nesting adds no weight:
.card .title { color: red; }
/* Specificity: 0-2-0 */
/* Equivalent nested version */
.card {
.title { color: red; }
}
/* Specificity: 0-2-0 — same */Good news when porting Sass, less so if you hoped nesting would settle a specificity fight. It won't.
Key Difference From Sass: Interpolation
Native nesting has no equivalent of Sass selector interpolation:
/* Sass — works */
$prefix: "btn";
.#{$prefix}-primary { color: white; }/* Native CSS — no interpolation */
/* This is not possible natively */If selectors have to be generated from a variable, a preprocessor or JavaScript is still the way.
Mixing With CSS Custom Properties
Custom properties and nesting go together well for component theming:
.button {
--bg: oklch(0.55 0.22 260);
--fg: white;
background: var(--bg);
color: var(--fg);
padding: 0.5rem 1rem;
border-radius: 6px;
&:hover {
--bg: oklch(0.45 0.22 260); /* Darker on hover */
}
&.danger {
--bg: oklch(0.55 0.22 25);
}
}Overriding --bg on hover rather than background keeps the hover rule from having to know the base colour. Small thing, but it's the reason I started structuring themes this way.
Migrating From Sass
Most Sass nesting converts one-to-one. The differences worth knowing:
| Feature | Sass | Native CSS |
|---|---|---|
| Basic nesting | ✅ | ✅ |
& concatenation | ✅ | ✅ |
@media nesting | ✅ | ✅ |
| Variables | $var | --var |
| Interpolation | #{$var} | ❌ Not supported |
| Mixins | @mixin | ❌ Not supported |
| Functions | darken() etc. | oklch(), color-mix() |
| Loops | @each, @for | ❌ Not supported |
If nesting and variables were the only Sass features a project used, native CSS covers it now.
Browser Support
All current browsers support it:
- Chrome 120+ (December 2023)
- Firefox 117+ (August 2023)
- Safari 17.2+ (December 2023)
- Edge 120+ (December 2023)
So the preprocessor can go, provided nothing else in the build depends on it. Fewer tools to keep current, and the CSS does the same job.