Run this yourself. Every number and every computed colour in this article came from
css-nesting-benchmark/in the Coding Dunia code-examples repo.npm install && npm testreprints them for your machine (it needsnpx playwright install chromiumonce).
Native CSS nesting is a browser feature that lets one selector be written inside another, the way Sass has allowed for over a decade, without a build step compiling it down first. By 2025 it shipped broadly enough that "do I still need Sass" turned into a real question. Most answers are feature checklists. I wanted measurements, so I generated one stylesheet in a syntax valid as both SCSS and native CSS, ran it through Sass, lightningcss and postcss-nesting, and loaded the results into headless Chromium 151 with Playwright. Some of what I found contradicts the first version of this article, and I've corrected it below.
What Converts Cleanly?
.card {
padding: 16px;
border-radius: 8px;
.title {
font-size: 1.25rem;
font-weight: 600;
}
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.featured {
border: 2px solid #2563eb;
}
}
Paste that into a stylesheet unchanged and current Chromium nests it. The first version of this article said a nested element or class selector needs an explicit & (& .title) in native CSS. That was true of early nesting drafts. It isn't true now: I fed .card { a { color: red; } } and .card { .title { color: red; } } to the browser as written and both applied, and all four routes (native, Sass, lightningcss, postcss-nesting) agreed. Class-to-class nesting, pseudo-classes, compound selectors like &.featured, .dark & and a nested @media also resolved to the same colour everywhere. Chromium 151 is the only browser I tested, so check Safari and Firefox before you delete the & from a codebase that targets old ones.
How Much Faster Does lightningcss Compile Than Sass?
Between 15 and 21 times, and postcss-nesting was no faster than Sass. I generated 100, 1,000 and 5,000 components, each flattening to 7 style rules, two of them inside a @media block, and timed every route with 7 interleaved rounds. Median compile time in milliseconds, run 1 and run 2 (Node 22.23.3, Apple M1 with 8 cores shared with other jobs, sass 1.105.2, lightningcss 1.33.0, postcss-nesting 14.0.3):
| Components | Sass | postcss-nesting | lightningcss (nesting lowered) |
|---|---|---|---|
| 100 | 30.8 / 20.1 | 34.3 / 23.2 | 1.44 / 1.19 |
| 1,000 | 217 / 156 | 246 / 195 | 12.5 / 10.2 |
| 5,000 | 1,014 / 1,019 | 1,453 / 1,544 | 60.0 / 61.8 |
At 5,000 components, which is a 2 MB source file and not a typical project, Sass took about a second and lightningcss took 60 ms. Is that a deciding factor? Not for a normal blog build. It matters for watch mode on a big design system, and it matters in CI. One limit: I used the pure-JavaScript sass package. The sass-embedded package runs the Dart binary and should be faster; I didn't measure it.
Does Nested CSS Ship Less?
Yes, a lot less once it's compressed, and that surprised me. If you keep the nesting and just minify, the parent selector is written once instead of once per rule, and gzip and brotli reward that repetition. At 5,000 components (identical in both runs):
| Route | Raw | gzip | brotli |
|---|---|---|---|
| Sass, compressed | 1,635 KB | 168.5 KB | 81.5 KB |
| lightningcss, nesting lowered and minified | 1,499 KB | 165.0 KB | 75.6 KB |
| nesting kept, minified with lightningcss | 1,311 KB | 88.1 KB | 13.9 KB |
That is 20% smaller raw, 48% smaller over gzip and 83% smaller over brotli than the Sass output. Don't quote the 83%. My generated stylesheet is far more repetitive than a real one, and brotli loves repetition. Take the direction: a stylesheet with a lot of nesting transfers noticeably smaller when you stop flattening it. Lowering the nesting with lightningcss landed within 8% of the Sass bytes at every level, which is what you'd expect from two tools producing the same flat CSS.
Where Does Native Nesting Behave Differently From Sass?
I wrote seven tiny stylesheets, fed each one as a single unchanged text to five routes, and read the resulting color back from Chromium with getComputedStyle. Three of them split the routes:
| Case | Native | Sass 1.105 | Sass 1.91 | lightningcss | postcss-nesting |
|---|---|---|---|---|---|
.card { &__title { } } | dropped | applied | applied | dropped | dropped |
.card, #featured { & p { } } against .card p.note | ID wins | class wins | class wins | ID wins | ID wins |
| a declaration written after a nested rule | last one wins | last one wins | hoisted, nested rule wins | last one wins | last one wins |
What Happens to &__title and &-suffix?
Nothing, silently. Sass glues &__title onto the parent name and produces .card__title. Native CSS has no such operation, so the selector is invalid and the browser drops the rule. The surprise is in the tools. I expected lightningcss and postcss-nesting to refuse it. Neither does. Both exit cleanly and emit a dead rule, __title.card { color: red; }, which parses as a type selector called __title combined with .card and matches nothing. The stylesheet ships, the build is green, and every BEM modifier written that way stops working. Grep for &[-_] before you switch tools, and rewrite those as full selectors.
Why Does the Selector List Change Specificity?
Because native nesting wraps the parent in :is(), and :is() takes the specificity of its most specific argument. I nested & p under .card, #featured, then added a competing .card p.note rule. For a <p class="note"> inside .card, native Chromium applied the nested rule, which means the .card branch picked up the ID's weight. The Sass output expands to .card p, #featured p, where the .card branch stays at class weight and loses to .card p.note. lightningcss and postcss-nesting emitted :is(.card, #featured) p and matched the browser. So moving a mixed list from Sass to native is a behaviour change, not a syntax one. Split the list into separate blocks.
Did Declarations After a Nested Rule Change?
They did, and the answer depends on your Sass version. With .box { color: red; & { color: green; } color: blue; }, the browser applies the declarations in source order and blue wins. Sass 1.91.0 hoisted the plain declarations above the nested rule, so green won. I scanned fifteen releases: 1.69.5, 1.77.6, 1.79.6, 1.89.0, 1.90.0 and 1.91.0 hoist, while 1.92.0 and every later one I tried (up to the current 1.105.2) keep source order and match the browser. If your lockfile is older than 1.92, deleting the Sass step can flip which colour wins. If it's newer, the two agree.
Is Nested CSS Slower for the Browser?
Mostly no, with one exception that is very slow. I parsed the generated stylesheet in a fresh Chromium page, nested and flattened, and compared how long it took to adopt the sheet and force style and layout for 2,000 elements. Parsing cost the same (about 4 to 5 ms). Applying didn't:
| Components | Nested, as written | Flattened by Sass | Flattened by lightningcss |
|---|---|---|---|
| 1,000 | 118 / 114 ms | 15.3 / 14.2 ms | 14.8 / 13.7 ms |
| 5,000 | 2,827 / 2,810 ms | 180 / 174 ms | 173 / 179 ms |
Nested was 8x slower at 1,000 components and 16x slower at 5,000, and the computed styles I checksummed (padding, border, font size and colour on every seventh component) were identical across all three, so this wasn't a rendering difference. To find out which rule caused it, I timed ten patterns one at a time at 1,000 components, nested against hand-flattened. Six showed no difference: & .title, &.featured, &:focus-visible, @media { & .title }, & .title { &.active } and & .title { & b } all ran within 1.2x of their flat twins, at 2.7 to 8.4 ms. The slow ones all had the same shape, a pseudo-class or pseudo-element nested under a nested descendant:
| Pattern, 1,000 components | Nested | Hand-flattened |
|---|---|---|
& .title { &:hover { } } | 107 / 117 ms | 9.2 / 9.7 ms |
& .title { &:first-child { } } | 114 / 117 ms | 9.2 / 9.7 ms |
& .title { &::after { } } | 176 / 196 ms | 13.1 / 14.2 ms |
& .title:hover { } as a sibling rule | 9.1 / 9.7 ms | 9.3 / 9.5 ms |
That &:hover under a child is about the most ordinary nesting there is. And the cost isn't linear. Doubling the component count roughly quadrupled it:
| Components | Nested & .title { &:hover } | Flattened | Ratio |
|---|---|---|---|
| 250 | 8.3 / 8.8 ms | 2.1 / 2.5 ms | 4.0x / 3.5x |
| 1,000 | 110 / 121 ms | 8.9 / 9.7 ms | 12.3x / 12.5x |
| 4,000 | 1,690 / 1,819 ms | 74.6 / 78.7 ms | 22.7x / 23.1x |
Why? I didn't read Blink's source, so this is a guess. A flat .c5 .title:hover is filed under the class title and rejected quickly for elements in the wrong component. The nested form arrives at the engine as a pseudo-class on top of an :is() of the parent chain, which looks like it has no class to file it under, so the engine appears to test all 1,000 of those rules against every element. That fits a quadratic curve. It doesn't prove the cause.
Two things bound the damage. The same pages took about 1 ms for a later, small style change, so this is the cost of a full recalculation (page load, a new stylesheet, a change that touches the whole document), not a per-frame one. And you need hundreds of such rules before it hurts: at 250 components the nested form cost 8 ms. A fix I verified: write the pseudo-class into the selector, & .title:hover { } as a sibling, which ran as fast as the flat version. I tested one browser engine, one version, on one machine.
In Chromium 151, a nested hover under a nested child applied 12x slower than the same rule written flat at 1,000 components, and the cost grew with the square of the component count.
Why Don't Mixins Convert?
// Sass mixin, no native CSS equivalent
@mixin truncate($lines: 1) {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-title {
@include truncate(2);
}
.card-description {
@include truncate(3);
}
Native CSS still has no construct that takes a parameter and expands into a reusable block of declarations. I checked: in Chromium 151, @mixin --m { } with @apply --m leaves the element unstyled. (Custom functions are a different story: @function --dbl(--x) with result: calc(var(--x) * 2) worked and produced 8px for --dbl(4px), so value-returning functions now exist natively, even though blocks of declarations don't.) Branching on a custom property is a third native piece, the CSS if() function, which Chromium 151 runs and Firefox 153 doesn't. A Sass mixin is a named, parameterized block of CSS declarations defined once with @mixin and inserted into any rule with @include. The closest native tool, custom properties, parameterizes a single value inside a rule, not an entire rule set:
/* Custom properties get partway there, for a single repeated value */
.truncate {
--lines: 1;
display: -webkit-box;
-webkit-line-clamp: var(--lines);
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-title {
--lines: 2;
}
That covers one varying number, but .truncate still has to be applied as a second class alongside .card-title, rather than the properties being inlined into the rule the way @include does. Writing the same clamp as a React style object is its own small trap, because -webkit-line-clamp becomes WebkitLineClamp and --lines keeps its dashes; the CSS to React style converter does that translation for a pasted rule.
Why Don't Loops and Conditionals Convert?
$spacings: (1: 4px, 2: 8px, 3: 16px, 4: 24px);
@each $key, $value in $spacings {
.p-#{$key} { padding: $value; }
.m-#{$key} { margin: $value; }
}
Four lines of Sass generate eight rules. Native CSS has no loop construct, so @each, @for and @if/@else for generating rules are Sass-only, with nothing on the roadmap I'm aware of. Systematic class generation either stays in Sass, moves to a utility framework that generates classes at build time, or gets hand-written if the set is small and stable.
How Does Every Sass Feature Map to Native CSS?
| Sass feature | Native CSS equivalent | Verdict |
|---|---|---|
| Selector nesting | & nesting, same syntax | Fully replaced |
Nested type and class selectors (a { }) | Work without & in Chromium 151 | Replaced |
&-suffix and &__element | None, silently dropped | Not replaced |
| Selector lists with mixed specificity | Wrapped in :is(), highest specificity wins | Replaced, behaviour changes |
@mixin/@include | None (custom properties cover single values only) | Not replaced |
@each/@for/@if | None | Not replaced |
@import partials/modules | Native @import exists but lacks Sass's module scoping | Partially replaced |
Math operations ($a + $b) | calc() covers most cases | Mostly replaced |
What Is a Reasonable Migration Path?
Don't treat it as all-or-nothing. A three-step audit before touching the build config:
- Run
grep -rE "@mixin|@include|@each|@for|@if" src/**/*.scssand count the files that match, then rungrep -rE "&[-_]" src/**/*.scssfor suffix concatenation. - If few files use those, migrate the rest to native nesting first and isolate the holdouts.
- Keep a minimal Sass build compiling only the holdout files, so the migration stays reversible.
Then check three things the measurements flagged: any &-suffix rewritten as a full selector, any selector list that mixes an ID with classes split into separate blocks, and any & .child { &:hover { } } shaped rule on a large stylesheet written as & .child:hover { }.
What Else Breaks After You Delete the Sass Build?
Source order. A Sass module graph gave you deterministic output through @use and @forward. Drop it for plain @import or a bundler's CSS handling and the cascade order becomes whatever your build tool decides, which changes between dev and production more often than anyone expects. Cascade layers fix this properly: declare @layer reset, base, components, utilities; once at the top of your entry file and the order stops depending on import sequence. Do that before you delete the Sass config, not after something breaks in staging. I didn't measure this one, so it's advice, not a result.
How Far Can You Trust These Numbers?
The computed colours are exact, and they were the same in both runs: that's what a browser does, with the versions named above. The timings are not exact. Every run shared eight cores with other jobs, the load average sat between 19 and 35 when each run started, and the compile times moved by up to a third between runs at the small sizes (Sass at 100 components: 30.8 ms, then 20.1 ms). The ratios held across all four runs of the Chromium script and both runs of the compile script, and the byte sizes were identical every time. The one engine is Chromium 151 headless shell. Firefox and Safari have their own style engines, and the quadratic result in particular might not reproduce there.
What Is the Takeaway?
Native CSS nesting replaced the Sass feature that appears in nearly every .scss file, and it needs less syntax than I told you earlier. It ships less CSS and it makes your build faster. It doesn't replace mixins or loops, it can't build a name out of & and a suffix, it changes the specificity of mixed selector lists, and one common shape, a hover nested under a child, is slow at scale in Chromium. Check which of those your code leans on before assuming the move is a straight swap.