Skip to content

What I Measured Moving From Sass to Native CSS Nesting

I compiled one stylesheet with Sass, lightningcss and postcss-nesting, then ran it in Chromium. Nested CSS ships far less but has one slow trap.

· · 14 min read

Updated: October 16, 2026

A black flat screen computer monitor with code

Quick Take

Native CSS nesting needs no explicit `&` in front of a nested element or class selector in current Chromium, which is a change from what most guides still say. It also differs from Sass in three measured ways: `&-suffix` concatenation does not exist, a selector list becomes `:is()`, and a nested `:hover` under a nested descendant makes style recalculation grow quadratically. Mixins and loops still need Sass.

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 test reprints them for your machine (it needs npx playwright install chromium once).

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):

ComponentsSasspostcss-nestinglightningcss (nesting lowered)
10030.8 / 20.134.3 / 23.21.44 / 1.19
1,000217 / 156246 / 19512.5 / 10.2
5,0001,014 / 1,0191,453 / 1,54460.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):

RouteRawgzipbrotli
Sass, compressed1,635 KB168.5 KB81.5 KB
lightningcss, nesting lowered and minified1,499 KB165.0 KB75.6 KB
nesting kept, minified with lightningcss1,311 KB88.1 KB13.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:

CaseNativeSass 1.105Sass 1.91lightningcsspostcss-nesting
.card { &__title { } }droppedappliedapplieddroppeddropped
.card, #featured { & p { } } against .card p.noteID winsclass winsclass winsID winsID wins
a declaration written after a nested rulelast one winslast one winshoisted, nested rule winslast one winslast 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:

ComponentsNested, as writtenFlattened by SassFlattened by lightningcss
1,000118 / 114 ms15.3 / 14.2 ms14.8 / 13.7 ms
5,0002,827 / 2,810 ms180 / 174 ms173 / 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 componentsNestedHand-flattened
& .title { &:hover { } }107 / 117 ms9.2 / 9.7 ms
& .title { &:first-child { } }114 / 117 ms9.2 / 9.7 ms
& .title { &::after { } }176 / 196 ms13.1 / 14.2 ms
& .title:hover { } as a sibling rule9.1 / 9.7 ms9.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:

ComponentsNested & .title { &:hover }FlattenedRatio
2508.3 / 8.8 ms2.1 / 2.5 ms4.0x / 3.5x
1,000110 / 121 ms8.9 / 9.7 ms12.3x / 12.5x
4,0001,690 / 1,819 ms74.6 / 78.7 ms22.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.

Share this Post on X Bluesky

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 featureNative CSS equivalentVerdict
Selector nesting& nesting, same syntaxFully replaced
Nested type and class selectors (a { })Work without & in Chromium 151Replaced
&-suffix and &__elementNone, silently droppedNot replaced
Selector lists with mixed specificityWrapped in :is(), highest specificity winsReplaced, behaviour changes
@mixin/@includeNone (custom properties cover single values only)Not replaced
@each/@for/@ifNoneNot replaced
@import partials/modulesNative @import exists but lacks Sass's module scopingPartially replaced
Math operations ($a + $b)calc() covers most casesMostly replaced

What Is a Reasonable Migration Path?

Don't treat it as all-or-nothing. A three-step audit before touching the build config:

  1. Run grep -rE "@mixin|@include|@each|@for|@if" src/**/*.scss and count the files that match, then run grep -rE "&[-_]" src/**/*.scss for suffix concatenation.
  2. If few files use those, migrate the rest to native nesting first and isolate the holdouts.
  3. 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.

Frequently Asked Questions

Is native CSS nesting the same syntax as Sass nesting?
Nearly, with three differences I checked in Chromium 151. A nested element or class selector like a { } or .title { } needs no leading & any more, so the old advice to write & a is out of date. Sass-style suffixes such as &__title do not work natively and are silently dropped. And a selector list like .card, #featured is wrapped in :is(), so the ID's specificity applies to the .card branch too.
Can native CSS nesting replace Sass mixins?
No, and this is the biggest real gap. Sass mixins let you define a reusable block of properties (@mixin, @include) with parameters, something native CSS has no equivalent for as of 2026. CSS custom properties and cascade layers cover some of the same use cases (a set of custom properties acting like configurable variables), but a parameterized, reusable block of arbitrary CSS is still Sass-only.
What about Sass functions and control flow (@if, @each, @for)?
Native CSS has no equivalent for Sass's @if/@else, @each, or @for loops used for generating repetitive CSS at build time (like a loop generating .col-1 through .col-12 utility classes). This is the second real gap alongside mixins. If your Sass usage leans heavily on generating classes programmatically, that logic either stays in Sass or moves to a different code-generation step entirely, native nesting doesn't touch this at all.
Is native CSS nesting slower in the browser?
Mostly no. Six of the nine patterns I timed in Chromium 151 cost the same nested or flattened. The exception is a pseudo-class or pseudo-element nested under a nested descendant, such as & .title { &:hover { } }: with 1,000 components it took about 110 to 143 ms to apply against 9 to 11 ms flattened, and the gap grew with the square of the component count. Writing the same rule as & .title:hover removed it.