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 any build step compiling it down first. Native CSS nesting shipped broadly enough by 2025 that "do I still need Sass" became a real question instead of a hypothetical one. I ran the actual experiment: took a mid-size component library's Sass files and converted them by hand, tracking exactly what converted cleanly and what didn't.
Quick take: Native CSS nesting handles selector nesting, the most common Sass usage, cleanly, with one difference: nested type selectors need an explicit
&prefix where Sass allows them bare. What it still can't do: Sass mixins and control-flow directives (@each,@for,@if) for generating CSS programmatically. Mostly-nesting usage can drop Sass; mixin-heavy usage can't yet.
What Converts Cleanly?
// Sass
.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;
}
}
/* Native CSS nesting, functionally identical */
.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;
}
}
Class-to-class nesting, pseudo-classes, and compound selectors (&.featured) all convert directly. The one change: .title needed an explicit & prefix (& .title) in the native version. Sass allows a bare nested selector because Sass resolves nesting entirely at build time, before the browser ever sees it. Native CSS nesting is resolved by the browser parser itself, and an unprefixed type selector nested directly (a { color: blue; } inside .card) risked ambiguity with the CSSOM's parsing rules in early nesting drafts, so the spec settled on requiring & for that specific case to keep the grammar unambiguous.
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. A Sass mixin is a named, parameterized block of CSS declarations defined once with @mixin and inserted into any rule with @include, functioning like a reusable function for style properties rather than a plain selector. The closest native tool, CSS custom properties, lets you parameterize a single value inside a rule, not an entire reusable rule set with multiple properties. Per the Sass documentation, a mixin can also accept default parameter values and multiple arguments, @mixin truncate($lines: 1, $fade: false), a level of reusable configurability that four years into native nesting still has no browser-native equivalent at all:
There's no native CSS construct that takes a parameter and expands into a reusable block of declarations - that gap is still Sass-only in 2026.
/* 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;
}
This works for the specific case of one varying number, but it's not a general mixin replacement, .truncate still has to be applied as a second class alongside .card-title, rather than the properties being inlined directly into .card-title's own rule the way @include does.
Why Don't Loops and Conditionals Convert?
// Sass @each generating repetitive utility classes
$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 separate CSS rules in that example. Native CSS has no loop construct at all, @each, @for, and @if/@else for generating rules are Sass-only, with no browser-native equivalent on the roadmap as of 2026. This kind of systematic class generation either stays in Sass, moves to a utility framework like Tailwind that generates these classes at build time through its own tooling, or gets hand-written if the set is small and stable enough not to need generation. Build-time code generation is any process that expands a small amount of source, a loop, a template, a config table, into a larger set of concrete output rules before the browser ever sees the file, and it is the one category native CSS nesting was never designed to replace.
How Does Every Sass Feature Map to Native CSS?
Six Sass features cover nearly everything a typical .scss file uses, and lining them up against their native equivalent makes the migration decision concrete instead of a vague "mostly, I think" answer.
| Sass feature | Native CSS equivalent | Verdict |
|---|---|---|
| Selector nesting | & nesting, same syntax mostly | Fully replaced |
Nested type selectors (a { }) | Requires explicit & a { } | Replaced, minor syntax change |
@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?
Dropping Sass entirely only makes sense if an audit of your actual .scss files shows minimal mixin and loop usage. Grep your codebase for @mixin, @include, @each, @for, and @if before deciding, if those show up in a handful of files, migrate the majority that's pure nesting to native CSS and keep Sass compiling just those few remaining files, rather than an all-or-nothing decision.
A three-step audit before touching the build config:
- Run
grep -rE "@mixin|@include|@each|@for|@if" src/**/*.scssand count how many files match. - If fewer than ten percent of files use those directives, migrate the rest to native nesting first and isolate the holdouts.
- Keep a minimal Sass build compiling only the holdout files rather than deleting the toolchain outright, so the migration stays reversible if native CSS closes the remaining gap later.
What Breaks After You Delete the Sass Build?
Two things bite in the week after the build step comes out, and neither shows up in a diff review.
The first is specificity drift. Sass flattened &:hover and & .child into plain selectors at compile time, so what shipped was exactly what you wrote. Native nesting resolves through :is(), which takes the highest specificity in the argument list. A rule nested under .card, #featured inherits the ID's specificity everywhere, including the branch that only ever matches .card. That is a real behaviour change, not a syntax one, and it surfaces as a style that suddenly refuses to be overridden. Splitting the mixed selector list into separate blocks fixes it.
The second is source order. A Sass module graph gave you deterministic output order 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 solve this properly: declare @layer reset, base, components, utilities; once at the top of your entry file and the order stops depending on import sequence at all. Do that before you delete the Sass config, not after something breaks in staging.
Conclusion
Native CSS nesting replaced the Sass feature that shows up in nearly every .scss file, selector nesting, cleanly and with only a minor syntax adjustment for bare type selectors. It didn't touch the two features that are hardest to give up once a codebase depends on them: parameterized mixins and programmatic class generation via loops. Check which category your Sass usage actually falls into before assuming the migration is all-or-nothing.