Is Sass Still Needed in 2026? Comparison with Native CSS and Setting Up Dart Sass
Yes, but not everywhere it used to be: variables, nesting and color/math are native CSS now, while mixins, loop-generated rules, maps, the module system and compile-time errors are still Sass-only. We measure why raw SCSS leaves not a single rule in the browser, compare sass with sass-embedded, and add Sass to Angular, Vite and ASP.NET Core.
Version note: The examples in this post were run with Dart Sass 1.108.2 (sass and sass-embedded), Angular 22.2 (TypeScript 6.0; the Angular CLI ships its own Sass 1.104.1), Vite 8.3, .NET 10 and the Chromium and WebKit builds of Playwright 1.65.
Guess First
A teammate styling the book card of Bookstore, the online bookstore used as the shared example across this site's series, thought "browsers understand nesting now" and dropped the SCSS file into the page without compiling it:
$brand: #b45309;
.book-card {
padding: 16px;
border: 1px solid $brand;
&:hover {
border-color: #1d4ed8;
}
&__title {
color: $brand;
}
.price {
font-weight: 700;
}
}
Does the card get its 16px of padding? Does the title turn brown? Is the price bold? Make a guess; the answer is the shortest way to explain why Sass still exists in 2026.
In this first post of the series we look at what Sass is, which gaps native CSS has closed in recent years, what Sass still does better, and how to add it to Angular, Vite and ASP.NET Core projects. The native CSS features themselves (nesting, custom properties, @layer, color functions) were covered in the "CSS3: Flexbox, Grid and Modern CSS" series; here we only look at how they differ from their Sass counterparts.
Sass Is a Build Step: the Browser Never Sees SCSS
I asked the browser. A small Playwright script feeds the same file to Chromium and WebKit three ways: raw SCSS, raw SCSS with the first line ($brand) removed, and the CSS compiled by Sass. Each time it prints how many rules survive in the style sheet and the computed values of three elements:
node scripts/01-raw-scss.mjs
chromium raw .scss rules=0 padding=0px title=rgb(0, 0, 0) price=400
chromium raw, no $ rules=1 padding=16px title=rgb(0, 0, 0) price=700
chromium compiled rules=4 padding=16px title=rgb(180, 83, 9) price=700
webkit raw .scss rules=0 padding=0px title=rgb(0, 0, 0) price=400
webkit raw, no $ rules=1 padding=16px title=rgb(0, 0, 0) price=700
webkit compiled rules=4 padding=16px title=rgb(180, 83, 9) price=700
With raw SCSS not a single rule reaches the page. The culprit is the first line: to a CSS parser, $brand: #b45309; at the top is not a variable declaration but the start of a selector. The parser keeps everything up to the first { as one selector, ends up with something like $brand: #b45309; .book-card, which is invalid, and throws away the rule together with its whole block. One Sass line silently wiped out the entire card style, and the console shows no error.
Remove that line and the picture changes, showing how far native nesting resembles Sass. &:hover and .price work in the browser too, because in native nesting & stands for the parent selector. &__title does not: to the browser & is a selector, not a piece of text, and you cannot glue __title onto it. color: $brand is dropped as an invalid value. The details of this difference were measured in the "Native CSS Nesting and @scope" post of the CSS3 series.
The CSS compiled by Sass has four rules, and all of them work as expected:
npx sass 01/book-card.scss
.book-card {
padding: 16px;
border: 1px solid #b45309;
}
.book-card:hover {
border-color: #1d4ed8;
}
.book-card__title {
color: #b45309;
}
.book-card .price {
font-weight: 700;
}
Think of Sass as a build-time tool: SCSS compiles to CSS the way TypeScript compiles to JavaScript. The file that reaches the browser contains no $brand, no mixins, no loops and no @use; all of it is resolved during compilation. That means two things: a Sass variable cannot change at runtime, and everything Sass does costs the browser nothing. Errors also surface at build time.
SCSS and the Indented Syntax
Sass has two syntaxes. The .scss file above uses the SCSS syntax: it keeps CSS's curly braces and semicolons, so every valid CSS file is also a valid SCSS file. The older indented syntax (files ending in .sass) relies on indentation instead of braces and semicolons:
$brand: #b45309
.book-card
padding: 16px
border: 1px solid $brand
&:hover
border-color: #1d4ed8
&__title
color: $brand
.price
font-weight: 700
I compiled both files and compared the outputs with diff; they are identical. The syntax you pick is purely a matter of taste. This series uses SCSS: the Angular CLI, Vite and nearly every library (Angular Material included) come with SCSS, and turning an existing CSS file into .scss is as easy as renaming it.
What Has Native CSS Covered, and What Does Sass Still Do Better?
In 2015 the three big reasons to use Sass were variables, nesting and color/math functions. In 2026 all three have native counterparts. The "native" column in the table below maps to features covered in the CSS3 series:
| Need | Sass | Native CSS (November 2026) |
|---|---|---|
| Variables | $brand, build time | --brand and var(), runtime; takes part in the cascade and inheritance |
| Nesting | Including text concatenation like &__title | Baseline; & is a selector, no text concatenation |
| Color | The sass:color module | color-mix(), relative color, oklch() |
| Math | sass:math | calc(), round(), mod(), clamp() |
| Mixins and parameterized reuse | @mixin, @include, @content | None; CSS mixins are in a draft spec, not Baseline |
| User-defined functions | @function | @function in Chromium only, not Baseline |
| Generating rules in loops | @each, @for, maps | None |
| Splitting into files | @use/@forward, namespaces, private members | @import (extra request, no namespaces) |
| Error handling | @error, the build fails | Invalid rules are silently dropped |
In the top half of the table the decision now usually goes to native CSS. Every value that must change at runtime (theme colors, the font size the user picked) should be a custom property; a Sass variable cannot do that. Simple nesting no longer needs a build step either. In the bottom half Sass still has no competitor. The file below comes from Bookstore's catalog page and does four things at once:
@use "sass:map";
// compile-time data: never reaches the browser
$breakpoints: (
"md": 48rem,
"lg": 64rem,
);
@mixin from($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint '#{$name}'. Expected one of: #{map.keys($breakpoints)}";
}
@media (width >= map.get($breakpoints, $name)) {
@content;
}
}
// generated by a loop: 4 badge classes for 4 stock states
$stock-states: (
"in-stock": #15803d,
"low": #b45309,
"preorder": #1d4ed8,
"out": #6b7280,
);
@each $state, $color in $stock-states {
.stock-badge--#{$state} {
border-color: $color;
color: $color;
}
}
.book-list {
display: grid;
gap: 16px;
@include from("md") {
grid-template-columns: repeat(2, 1fr);
}
@include from("lg") {
grid-template-columns: repeat(4, 1fr);
}
}
The breakpoints live in one map and the from() mixin uses them by name; since native CSS cannot use var() inside a media query, you would have to repeat the breakpoint values in every media query by hand. @each generates the four badge classes in one loop. The relevant part of the output:
.stock-badge--in-stock {
border-color: #15803d;
color: #15803d;
}
...
@media (width >= 48rem) {
.book-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width >= 64rem) {
.book-list {
grid-template-columns: repeat(4, 1fr);
}
}
The fourth job is the least discussed and the most useful in a team: compile-time errors. If someone writes from("xl"), the build fails:
Error: "Unknown breakpoint 'xl'. Expected one of: md, lg"
╷
41 │ @include from("xl") {
│ ^^^^^^^^^^^^^^^^^^^
╵
01/typo.scss 41:5 root stylesheet
In native CSS the same typo would be a silently dropped rule, noticed only when someone opens the page on a wide screen. The module system and private members (the fourth post of the series) belong on this list too: in a large style code base, Sass controls which variable belongs to whom and what is exposed.
Installation: sass or sass-embedded?
Dart Sass is the only official Sass implementation. The Sass blog announced on 23 October 2025 that LibSass, written in C++, had reached end-of-life, and the node-sass package built on it already sits on npm with a "no longer supported, use sass or sass-embedded" notice. If you find node-sass in an old project, replacing it comes first; the module system and the new color functions simply do not exist there.
Dart Sass has two packages on npm. sass is the Dart code compiled to JavaScript; it has no native dependencies and runs anywhere. sass-embedded exposes the same JavaScript API but delegates compilation to a native Dart Sass process for your platform. I measured the difference with a deliberately heavy file that looks nothing like Bookstore: a @for loop generating 3000 rules, each calling color and math functions, for 300 KB of output. The script compiles the file once with each package to warm up, then 30 times, and prints the average:
npm install --save-dev --save-exact sass@1.108.2 sass-embedded@1.108.2
node scripts/01-bench.mjs
sass (pure JS) 43 ms per compile
sass-embedded (Dart VM) 26 ms per compile
I repeated the measurement several times; the absolute numbers moved between 38 and 75 ms depending on the machine's load, but sass-embedded was 30 to 45 percent faster every time. You feel the difference in an Angular project with thousands of files, or in a pipeline that recompiles an Angular Material theme on every build. For compiling a single file from the CLI, or in an environment where the native binary cannot run (a playground running in the browser, for example), the sass package is enough.
Sass in Angular, Vite and ASP.NET Core
In Angular you choose Sass when you create the project. I created Bookstore's Angular client for this series with:
npx @angular/cli@22.2.7 new bookstore-web --style=scss --ssr=false
The CLI creates src/styles.scss and a .scss file for every component, and writes two settings to angular.json: new components get .scss files, and inline styles in templates are read as SCSS too.
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
...
"options": {
"inlineStyleLanguage": "scss",
"styles": ["src/styles.scss"]
}
There is no need to install a sass package; @angular/build brings it as its own dependency and compiles with sass-embedded by default:
npm ls sass sass-embedded
bookstore-web@0.0.0
+-- @angular/build@22.2.7
| +-- sass-embedded@1.104.1
| +-- sass@1.104.1
...
The version matters: in an Angular project your styles are compiled by the Sass version @angular/build pins, not by the one you write in your package.json. We will come back to this when preparing for Dart Sass 2.0 in post 07 of the series. Bookstore's first version has a header, a four-book catalog and a BookCard component whose stylesheet looks much like the card style above for now; ng build and ng test pass cleanly.
Vite is even simpler: when a .scss file is imported, Vite looks for the compiler itself. Without the package the build stops with this error:
[plugin vite:css] /home/user/bookstore-vite/src/book-card.scss
Error: Preprocessor dependency "sass-embedded" not found. Did you install it? Try `npm install -D sass-embedded`.
npm install -D sass-embedded is all it takes; Vite 8 then produces the same CSS without touching any config file. The error suggesting sass-embedded rather than sass is consistent with the measurement above.
On the ASP.NET Core side, if there is no frontend build chain, the simplest route is to define the Sass CLI as an npm script and call it from MSBuild. The package.json of the Bookstore.Web project:
{
"name": "bookstore-web-styles",
"private": true,
"scripts": {
"css:build": "sass Styles/site.scss wwwroot/css/site.css --style=compressed --no-source-map",
"css:watch": "sass Styles/site.scss wwwroot/css/site.css --watch"
},
"devDependencies": {
"sass": "1.108.2"
}
}
I added two targets to the project file. The first installs the packages if node_modules is missing; the second runs Sass only when an .scss file is newer than site.css:
<ItemGroup>
<SassSource Include="Styles/**/*.scss" />
</ItemGroup>
<!-- npm packages are installed once, before the first Sass compile -->
<Target Name="NpmInstall" BeforeTargets="CompileSass" Condition="!Exists('node_modules')">
<Exec Command="npm ci" />
</Target>
<!-- runs only when an .scss file is newer than site.css -->
<Target Name="CompileSass" BeforeTargets="BeforeBuild" Inputs="@(SassSource)" Outputs="wwwroot/css/site.css">
<Exec Command="npm run css:build" />
<ItemGroup>
<Content Include="wwwroot/css/site.css" Exclude="@(Content)" />
</ItemGroup>
</Target>
The Content line inside the target matters. MSBuild lists the files under wwwroot before any target runs, and on a clean checkout site.css does not exist yet at that point. Without this line the file does not make it into the MapStaticAssets manifest on the first build. The last lines of dotnet build in a clean folder:
dotnet build
> css:build
> sass Styles/site.scss wwwroot/css/site.css --style=compressed --no-source-map
Bookstore.Web -> C:\src\bookstore\aspnet\Bookstore.Web\bin\Debug\net10.0\Bookstore.Web.dll
Build succeeded.
0 Warning(s)
0 Error(s)
On the second dotnet build the css:build line does not appear; it runs again after you change an .scss file. When I started the app and requested /css/site.css, I got a 200 with text/css. The fingerprinting and compression side of MapStaticAssets was covered in the "Static Assets and UI Infrastructure" post of the ASP.NET Core MVC series. There are also community NuGet packages that bundle Dart Sass; if you want to remove Node.js from the build machine entirely you can look at them, but then the package maintainer's schedule decides your Sass version.
When Not to Add Sass
Sass means one more dependency, one more build step and one more language to teach the team. In a new project I would not add it when:
- The style code is a few hundred lines with no need for shared breakpoints, loop-generated classes or shared mixins: native nesting and custom properties are enough.
- The project uses Tailwind CSS v4: Tailwind's documentation says plainly that v4 is not designed to be used with Sass, Less or Stylus; Tailwind itself handles imports, prefixes and nesting (details in the Tailwind CSS series).
- Every value changes at runtime (user themes, white-label colors): those have to be custom properties anyway; at most Sass helps generate them.
- There is no build step (CSS pasted into a CMS, an e-mail template): either you run Sass in a separate build or you do not use it at all.
On the other hand, if you use Angular Material in an Angular project, generate many classes or custom properties from design tokens, or several teams share hundreds of style files, Sass pays for itself quickly. Bookstore's Angular client is in this second group.
In Production
Pin the Sass version. That is why the package.json above says "sass": "1.108.2": even within Dart Sass 1.x there are releases with "potentially breaking bug fix" notes; the CHANGELOG for 1.106.0 lists six items with that note. Without a lock file your CI can produce different CSS one morning.
Measure the compiled output. The places where Sass multiplies the code you write (mixins, @each, @extend) can grow the generated CSS unexpectedly; the ten-line benchmark file producing 300 KB of output is an exaggerated example. Watch the CSS size in dist after the build; the anyComponentStyle budget that comes with a new Angular project (a warning at 4 kB and an error at 8 kB in production) already does this per component.
Do not ignore deprecation warnings. Dart Sass 2.0 is targeted for December 2026, and most of today's warnings will become errors there. If you see DEPRECATION WARNING lines in your build log, write down how many there are, even before reading post 07 of the series.
Common Pitfalls
Shipping an SCSS file without compiling it. As the experiment at the start of this post showed, it does not raise an error; it just destroys rules. Putting an .scss file under wwwroot in ASP.NET Core and referencing it with <link> is the most common form of this.
Treating a Sass variable as a runtime value. You cannot change $brand from JavaScript, nor per media query; once compilation finishes the variable is gone. Everything that changes at runtime should be a custom property. We will see how to use the two together in the next post.
Assuming native nesting and Sass nesting are the same thing. When moving from Sass to native CSS, BEM concatenations like &__title stop working and nobody tells you.
Trying to use Tailwind v4 and Sass in the same file. Both are tools that process the stylesheet; since neither knows the other's order or syntax, the result is unpredictable.
A Small Experiment
★☆☆ Move the $brand line in 01/book-card.scss inside the .book-card block and run node scripts/01-raw-scss.mjs again. Explain how many rules survive in raw SCSS and why.
★★☆ Add an "xl": 80rem breakpoint and a fifth stock state to 01/what-sass-adds.scss. Count the rules and media queries in the output, then work out how many lines the same CSS would take to write by hand.
★★★ Add a second entry file (Styles/admin.scss) to Bookstore.Web. Change the css:build script to use Sass's folder-to-folder mode, Styles:wwwroot/css, and update the MSBuild target's Outputs to cover both files. Verify that after the first build on a clean checkout both CSS files are in the manifest.
In the next post we get into Sass's basic syntax: using $variable together with custom properties, nesting and & with BEM, interpolation, placeholders, and how too much nesting turns into a specificity and size problem in the compiled CSS.