CSS Module 19 – Sass modules, migrating off @import and Sass in production
Sass in production and when to use native CSS
Run Sass in a Vite build with sass-embedded, test it with True, lint it with stylelint-scss, and decide feature by feature when native CSS is enough.
What you will learn
- Set up Sass in a Vite build with sass-embedded, source maps and shared settings
- Test Sass functions and mixins with True and lint SCSS with stylelint-scss
- Decide feature by feature whether to keep Sass or use native CSS (nesting, custom properties, color-mix(), if() and @function)
Before you start
What you’ll learn · Before you start
What you will learn
- Set up Sass in a Vite build with sass-embedded, source maps and shared settings
- Test Sass functions and mixins with True and lint SCSS with stylelint-scss
- Decide feature by feature whether to keep Sass or use native CSS (nesting, custom properties, color-mix(), if() and @function)
Before you start
On this page
Sass in production means treating the stylesheet build like the rest of the code: a pinned compiler inside the
build tool, deprecations that fail the build once they are fixed, tests for the functions and mixins, and a linter.
It also means deciding, job by job, what still needs Sass. Native CSS now covers much of what Sass was first used
for: nesting, custom properties and color-mix() are Baseline widely available. Sass still does what CSS cannot do
at all: loops and maps that generate rules at build time, modules with private members and configuration, and maths
that ends up as one fixed value. A useful rule follows from that: keep Sass for libraries, generated utilities and
design-token pipelines, and write component styles in native CSS with custom properties, so that themes can change
while the page runs. The examples below were run with Dart Sass 1.105.1, Vite 8.3.4, True 10.1.0 and stylelint
17.16.0 on Node.js 24.21.0, and each recorded output is shown under its example.
Sass in a Vite build
Vite compiles .scss files without a plugin once a Sass package is installed. It looks for sass-embedded first
and falls back to sass, and its documentation recommends sass-embedded for speed. Three settings matter:
css.preprocessorOptions.scsspasses options to Sass’s JavaScript API for every file:quietDeps,fatalDeprecations,silenceDeprecations,loadPathsandimportersall work there, so the CI rules from the previous lesson can live in the build itself. With the config below, addingmargin: (8px / 2)toapp.scssmadevite buildfail with theslash-diverror.additionalDataprepends code to every Sass file. Use it only for members such as variables: Vite’s documentation warns that real styles in it are duplicated into the bundle once per file.css.devSourcemapturns on CSS source maps in the development server, so the browser’s developer tools show the.scssfile and line of each rule.
vite-app/vite.config.mjs
import { defineConfig } from 'vite';
export default defineConfig({
css: {
// Source maps for CSS in the dev server: DevTools then shows the .scss file and line of each rule.
devSourcemap: true,
preprocessorOptions: {
// Passed to Sass's JavaScript API for every .scss file.
scss: {
quietDeps: true,
fatalDeprecations: ['import', 'global-builtin', 'slash-div', 'color-functions', 'if-function'],
},
},
},
build: {
sourcemap: true,
},
}); src/styles/app.scss · vite-app/src/styles/app.scss
@use "tokens";
.btn {
border-radius: tokens.$radius;
// A loop Sass runs once, at build time: three size classes.
@each $name, $size in tokens.$sizes {
&--#{$name} {
font-size: $size;
}
}
&--primary {
background: tokens.$brand;
color: white;
}
} src/styles/_tokens.scss · vite-app/src/styles/_tokens.scss
$brand: #0b5fff !default;
$radius: 6px !default;
$sizes: (sm: 0.875rem, md: 1rem, lg: 1.25rem); src/main.js · vite-app/src/main.js
// Importing the stylesheet makes it part of the build.
import './styles/app.scss';
document.querySelector('.btn')?.addEventListener('click', () => console.log('saved')); vite-app/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sass in a Vite build</title>
<script type="module" src="/src/main.js"></script>
</head>
<body>
<button class="btn btn--primary btn--md">Save</button>
</body>
</html> package.json
{
"private": true,
"type": "module",
"description": "What the examples of this lesson need: npm install, then node <file>.mjs in the example's folder.",
"devDependencies": {
"bootstrap": "5.3.8",
"sass": "1.105.1",
"sass-1.78": "npm:sass@1.78.0",
"sass-embedded": "1.105.1",
"sass-true": "10.1.0",
"stylelint": "17.16.0",
"stylelint-config-standard-scss": "17.0.0",
"vite": "8.3.4",
"vitest": "5.0.3"
}
} // Builds the app with Vite, lists what the build wrote, and checks the CSS source maps in the dev server.
// Run it in this folder after `npm install` in the examples folder: node build.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, relative } from 'node:path';
import { build, createServer } from 'vite';
// Which Sass packages are installed: Vite uses sass-embedded when it can load it, and sass otherwise.
const require = createRequire(import.meta.url);
for (const name of ['sass-embedded', 'sass']) {
try {
const main = require.resolve(name);
const dir = main.slice(0, main.lastIndexOf(`node_modules/${name}/`) + `node_modules/${name}/`.length);
console.log(`${name} ${JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version} is installed`);
} catch {
console.log(`${name} is not installed`);
}
}
await build({ logLevel: 'silent' });
const files = (dir) => readdirSync(dir).flatMap((f) => (statSync(join(dir, f)).isDirectory() ? files(join(dir, f)) : [join(dir, f)]));
console.log('\nThe build wrote:');
for (const file of files('dist').sort()) console.log(` ${file.padEnd(34)} ${String(statSync(file).size).padStart(5)} bytes`);
const css = files('dist').find((f) => f.endsWith('.css'));
console.log(`\n${css}:\n${readFileSync(css, 'utf8')}`);
console.log(`CSS source map in the build: ${files('dist').some((f) => f.endsWith('.css.map')) ? 'yes' : 'no'}`);
// The dev server: the CSS it serves for app.scss, with css.devSourcemap on.
const server = await createServer({ logLevel: 'silent', server: { middlewareMode: true, ws: false }, appType: 'custom' });
const served = await server.transformRequest('/src/styles/app.scss');
await server.close();
const map = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(served.code);
const sources = map ? JSON.parse(Buffer.from(map[1], 'base64').toString()).sources.map((s) => relative('.', s)) : [];
console.log(`CSS source map in the dev server: ${map ? `yes, inline, with the sources ${sources.join(' and ')}` : 'no'}`); Output
sass-embedded 1.105.1 is installed
sass 1.105.1 is installed
The build wrote:
dist/assets/index-BTOKGPD_.js 796 bytes
dist/assets/index-BTOKGPD_.js.map 448 bytes
dist/assets/index-CntIoS7G.css 146 bytes
dist/index.html 359 bytes
dist/assets/index-CntIoS7G.css:
.btn{border-radius:6px}.btn--sm{font-size:.875rem}.btn--md{font-size:1rem}.btn--lg{font-size:1.25rem}.btn--primary{color:#fff;background:#0b5fff}
CSS source map in the build: no
CSS source map in the dev server: yes, inline, with the sources src/styles/app.scss and src/styles/_tokens.scss
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node build.mjs
The @each loop ran once, at build time, and left three plain classes. Vite then minified the CSS with Lightning
CSS, its default CSS minifier, which wrote white as #fff and moved color before background, a harmless
reordering of two unrelated properties. Note the two source-map lines. build.sourcemap: true wrote a map for the
JavaScript but none for the CSS: in Vite 8.3.4 CSS source maps come from the development server, where the map
points at both Sass files. If production CSS needs a map, for example to report errors, compile it with the Sass
command line and --source-map, or with sourceMap: true in the JavaScript API, in a separate step.
sass-embedded or sass
The two packages are the same compiler, released together with the same version number and the same JavaScript
API. sass is Dart Sass compiled to JavaScript; sass-embedded runs the compiler as a native program in the Dart
virtual machine and talks to it from Node.js, which the Sass site says gives a substantial performance boost. The
script below compiles all of Bootstrap 5.3.8 with each, through a long-lived asynchronous compiler, which is how Vite
calls them, and also with the sass package’s synchronous API:
// Times a compile of all of Bootstrap 5.3.8 with sass-embedded and with the pure-JavaScript sass package, through a
// long-lived async compiler as Vite uses them, and with sass's synchronous API. Medians of 5 runs after 2 warm-ups.
// Run it in this folder after `npm install` in the examples folder: node bench.mjs
import * as sass from 'sass';
import * as embedded from 'sass-embedded';
const SOURCE = '@use "pkg:bootstrap";';
const options = (pkg) => ({ importers: [new pkg.NodePackageImporter(process.cwd())], quietDeps: true, style: 'compressed', logger: pkg.Logger.silent });
const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];
async function time(compile) {
for (let i = 0; i < 2; i++) await compile();
const runs = [];
for (let i = 0; i < 5; i++) {
const start = performance.now();
await compile();
runs.push(performance.now() - start);
}
return Math.round(median(runs));
}
const embeddedCompiler = await embedded.initAsyncCompiler();
const jsCompiler = await sass.initAsyncCompiler();
const results = [
['sass-embedded, async compiler', await time(() => embeddedCompiler.compileStringAsync(SOURCE, options(embedded)))],
['sass, async compiler', await time(() => jsCompiler.compileStringAsync(SOURCE, options(sass)))],
['sass, synchronous compileString()', await time(() => sass.compileString(SOURCE, options(sass)))],
];
await embeddedCompiler.dispose();
await jsCompiler.dispose();
console.log(`All of Bootstrap 5.3.8, compressed, Dart Sass ${sass.info.split('\t')[1]} on Node.js ${process.versions.node}:`);
for (const [label, ms] of results) console.log(` ${label.padEnd(34)} median ${String(ms).padStart(5)} ms`);
console.log(`sass-embedded was ${(results[1][1] / results[0][1]).toFixed(1)} times as fast as sass through the same async API.`); Output
All of Bootstrap 5.3.8, compressed, Dart Sass 1.105.1 on Node.js 24.21.0: sass-embedded, async compiler median 1201 ms sass, async compiler median 6587 ms sass, synchronous compileString() median 1525 ms sass-embedded was 5.5 times as fast as sass through the same async API.
This output changes from run to run: Timings change from run to run and from computer to computer; the pattern checks the shape of the report, and the recorded run shows the numbers of one run.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bench.mjs
The recorded run was made on an Apple silicon laptop of the M5 generation, with 18 CPU cores, while it was busy with
other builds, which slows every number but keeps their order. Eight more rounds on the same computer, also under
load, gave medians between 0.27 and 0.57 seconds for sass-embedded and between 1.9 and 4.6 seconds for sass
through the asynchronous API. Most of the gap is the cost of asynchronous callbacks in the JavaScript version: the
Sass documentation notes that the sass package’s synchronous functions are more than twice as fast as its
asynchronous ones, and in the recorded run
the synchronous call needed a fraction of the asynchronous time. Because Vite uses the asynchronous API, installing
sass-embedded is the cheapest speed-up a Vite project with a lot of Sass can get. One difference between the
packages appeared when comparing their output: with the default
charset: true, the sass package started compressed CSS that contains non-ASCII characters with a byte-order
mark and sass-embedded did not, so the two files differed in their first byte only. Set charset: false, or
ignore a leading byte-order mark, before comparing builds byte for byte.
Pin the compiler and diff the CSS when you upgrade
Sass releases change how some values are written, so the same source can compile to different bytes after an
upgrade. Pin the exact version in package.json, commit the lockfile, and upgrade in a commit of its own with the
compiled CSS compared before and after. Bootstrap 5.3.8 pins Sass 1.78.0 for its own build; the script below
compiles a customised Bootstrap with that version and with 1.105.1:
upgrade/custom.scss
// A customised Bootstrap 5.3.8, loaded the way its documentation shows.
$primary: #0f766e;
$enable-shadows: true;
@import "bootstrap/scss/bootstrap"; upgrade/vendor.mjs
// Copies Bootstrap's package.json and Sass sources from the installed package into ./node_modules/bootstrap, so
// that the paths in every message read as they do in a project folder ("node_modules/bootstrap/scss/…").
import { cpSync, mkdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
export function installBootstrap(into = '.') {
const from = dirname(createRequire(import.meta.url).resolve('bootstrap/package.json'));
const to = resolve(into, 'node_modules', 'bootstrap');
if (from === to) return;
rmSync(to, { recursive: true, force: true });
mkdirSync(to, { recursive: true });
cpSync(join(from, 'package.json'), join(to, 'package.json'));
cpSync(join(from, 'scss'), join(to, 'scss'), { recursive: true });
} // Compiles the same stylesheet with Sass 1.78.0 (the version Bootstrap 5.3.8 pins) and with Sass 1.105.1, then
// compares the CSS. Needs both: npm install sass@1.105.1 sass-1.78@npm:sass@1.78.0, then: node upgrade.mjs
import * as newSass from 'sass';
import * as oldSass from 'sass-1.78';
import { installBootstrap } from './vendor.mjs';
installBootstrap('.');
const compile = (sass, style) => sass.compile('custom.scss', { loadPaths: ['node_modules'], style, logger: sass.Logger.silent }).css;
const version = (sass) => sass.info.split('\t')[1];
for (const style of ['compressed', 'expanded']) {
const before = compile(oldSass, style).length;
const after = compile(newSass, style).length;
console.log(`${style.padEnd(10)} Sass ${version(oldSass)}: ${before.toLocaleString('en')} bytes, Sass ${version(newSass)}: ${after.toLocaleString('en')} bytes (${after > before ? '+' : ''}${(after - before).toLocaleString('en')})`);
}
// Line by line, in expanded output.
const a = compile(oldSass, 'expanded').split('\n');
const b = compile(newSass, 'expanded').split('\n');
const changed = a.map((line, i) => [line.trim(), b[i]?.trim()]).filter(([x, y]) => x !== y);
const percentRgb = changed.filter(([, y]) => /rgb(?:\(|%28)[\d.]+%/.test(y));
console.log(`\n${a.length} lines each; ${changed.length} differ, ${percentRgb.length} of them because a colour is now written as rgb() percentages.`);
for (const [x, y] of changed.slice(0, 3)) console.log(`- ${x}\n+ ${y}`);
const inSvg = changed.filter(([, y]) => y.includes('data:image/svg+xml'));
console.log(`\n${inSvg.length} of the changed lines are SVG icons in data: URLs, for example:\n- ${inSvg[0][0]}\n+ ${inSvg[0][1]}`); Output
compressed Sass 1.78.0: 229,554 bytes, Sass 1.105.1: 234,800 bytes (+5,246)
expanded Sass 1.78.0: 273,128 bytes, Sass 1.105.1: 278,384 bytes (+5,256)
11895 lines each; 127 differ, 127 of them because a colour is now written as rgb() percentages.
- --bs-primary-text-emphasis: #062f2c;
+ --bs-primary-text-emphasis: rgb(2.3529411765%, 18.5098039216%, 17.2549019608%);
- --bs-secondary-text-emphasis: #2b2f32;
+ --bs-secondary-text-emphasis: rgb(16.9411764706%, 18.3529411765%, 19.6078431373%);
- --bs-success-text-emphasis: #0a3622;
+ --bs-success-text-emphasis: rgb(3.9215686275%, 21.1764705882%, 13.1764705882%);
4 of the changed lines are SVG icons in data: URLs, for example:
- --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2387bbb7'/%3e%3c/svg%3e");
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgb%2852.9411764706%, 73.137254902%, 71.568627451%%29'/%3e%3c/svg%3e");
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node upgrade.mjs
The compressed CSS grew by 5,246 bytes, and every one of the 127 changed lines is a colour that Sass 1.105.1 writes
as rgb() with percentages where 1.78.0 wrote hex. No screen can show the difference: Bootstrap mixes these
colours, the mixes have fractional channels, and 1.78.0 rounded each channel to a whole number to write hex while
1.105.1 keeps the exact value. For --bs-primary-text-emphasis, the green channel is 47.2 in the new output and 47
(2f) in the
old. Four of the changes are inside SVG icons in data: URLs, which needed checking separately: loaded as images in
Chromium 153, Firefox 155 and WebKit 26.6 (with Playwright 1.63.0), all four icons drew, and their painted pixels
matched the old icons’ colours to within one step in 255. That check is the procedure in small: diff the expanded
output, sort the changes into kinds, and test the
kinds that could break, before shipping the upgrade.
Testing Sass with True
Functions and mixins are code, and they break like code. True (the npm package sass-true, 10.1.0, BSD-3-Clause
licence, by OddBird) is a testing library written in Sass: assert-equal compares two Sass values, and an assert
block compares the CSS a mixin writes with the CSS you expect. Tests can run on their own as a Sass compile, or
inside a JavaScript test runner through runSass(), which takes the runner’s describe and it functions and
reports each Sass test as a test of that runner. True needs Dart Sass 1.45.0 or later and the default expanded
output style. Bootstrap 5.3.8 lists it among its development dependencies.
test/library.test.scss · testing/test/library.test.scss
@use "pkg:sass-true" as *;
@use "../functions" as fn;
@use "../mixins";
@include describe("rem()") {
@include it("divides by the 16px root") {
@include assert-equal(fn.rem(24px), 1.5rem);
}
@include it("accepts another root size") {
@include assert-equal(fn.rem(24px, 12px), 2rem);
}
}
@include describe("tint()") {
@include it("returns white at 100%") {
@include assert-equal(fn.tint(#0b5fff, 100%), white);
}
@include it("keeps the colour at 0%") {
@include assert-equal(fn.tint(#0b5fff, 0%), #0b5fff);
}
}
@include describe("focus-ring") {
@include it("writes an outline and an offset") {
@include assert {
@include output {
@include mixins.focus-ring(#c2185b);
}
@include expect {
outline: 3px solid #c2185b;
outline-offset: 2px;
}
}
}
} test/library.test.mjs · testing/test/library.test.mjs
// Runs the Sass tests of library.test.scss as Vitest tests.
import { fileURLToPath } from 'node:url';
import sassTrue from 'sass-true';
import { describe, it } from 'vitest';
sassTrue.runSass({ describe, it }, fileURLToPath(new URL('./library.test.scss', import.meta.url))); testing/_functions.scss
@use "sass:color";
@use "sass:math";
// rem(24px) is 1.5rem when the root font size is 16px.
@function rem($px, $root: 16px) {
@if math.unit($px) != "px" {
@error "rem() expects a px value, got #{$px}";
}
@return math.div($px, $root) * 1rem;
}
// tint(#0b5fff, 20%) mixes 20% white into the colour.
@function tint($color, $amount) {
@return color.mix(white, $color, $amount);
} testing/_mixins.scss
// A visible focus ring that never depends on the browser's default.
@mixin focus-ring($color: #0b5fff, $width: 3px) {
outline: $width solid $color;
outline-offset: 2px;
} testing/vitest.config.mjs
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.mjs'],
// Run the tests again in watch mode when a Sass file changes.
forceRerunTriggers: ['**/*.scss'],
},
}); // Runs the Vitest suite and prints each test with its result (from Vitest's JSON report, without timings). Then it
// changes the default root size of rem() to 10px, as a careless edit might, runs the suite again and puts the file
// back. Run it in this folder after `npm install` in the examples folder: node run-tests.mjs (or npx vitest run)
import { readFileSync, writeFileSync } from 'node:fs';
import { startVitest } from 'vitest/node';
async function run(title) {
console.log(`=== ${title}`);
const vitest = await startVitest('test', [], { watch: false, reporters: [['json', { outputFile: 'report.json' }]] });
await vitest.close();
const report = JSON.parse(readFileSync('report.json', 'utf8'));
for (const file of report.testResults) {
for (const test of file.assertionResults) {
console.log(`${test.status === 'passed' ? 'PASS' : 'FAIL'} ${test.ancestorTitles.join(' > ')} > ${test.title}`);
// The assertion and the two values; the stack trace after them is left out.
for (const message of test.failureMessages) {
const lines = message.split('\n');
const end = lines.findIndex((l) => l.startsWith(' at '));
console.log(lines.slice(0, end === -1 ? undefined : end).filter((l) => l.trim()).map((l) => ` ${l}`).join('\n'));
}
}
}
console.log(`${report.numPassedTests} passed, ${report.numFailedTests} failed\n`);
return report.numFailedTests;
}
const failedAsIs = await run('The library as it is');
const original = readFileSync('_functions.scss', 'utf8');
let failedAfterEdit = 0;
try {
writeFileSync('_functions.scss', original.replace('$root: 16px', '$root: 10px'));
failedAfterEdit = await run('After changing the default root size of rem() to 10px');
} finally {
writeFileSync('_functions.scss', original);
}
// Vitest sets a failing exit status for the second run; this script succeeds when the suite passes as it is and
// catches the edit.
process.exitCode = failedAsIs === 0 && failedAfterEdit > 0 ? 0 : 1; Output
=== The library as it is
JSON report written to report.json
PASS rem() > divides by the 16px root
PASS rem() > accepts another root size
PASS tint() > returns white at 100%
PASS tint() > keeps the colour at 0%
PASS focus-ring > writes an outline and an offset
5 passed, 0 failed
=== After changing the default root size of rem() to 10px
JSON report written to report.json
FAIL rem() > divides by the 16px root
AssertionError [ERR_ASSERTION]: divides by the 16px root [type: assert-equal] -- numbers may need to be rounded before comparison
- Expected
+ Received
- [number] 1.5rem
+ [number] 2.4rem
PASS rem() > accepts another root size
PASS tint() > returns white at 100%
PASS tint() > keeps the colour at 0%
PASS focus-ring > writes an outline and an offset
4 passed, 1 failed
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node run-tests.mjs
The five tests passed, then one edit to a default argument changed rem(24px) from 1.5rem to 2.4rem, and the
first test reported both values. Vitest does not watch .scss files by default, which is why the config lists them
in forceRerunTriggers: without it, vitest --watch would not rerun when only a Sass file changed.
Linting SCSS with stylelint-scss
The compiler catches what is deprecated; a linter catches what is allowed but unwise.
stylelint-config-standard-scss 17.0.0 sets stylelint 17 up for SCSS with the stylelint-scss plugin (7.3.0) and
already turns on scss/no-global-function-names, which reports map-get() and darken() with their module
replacements. Rules that keep a codebase on the module system have to be added:
scss/at-use-no-unnamespacedreports@use … as *;scss/no-unused-private-membersreports private members that nothing reads;scss/partial-no-importand the core ruleat-rule-disallowed-listkeep@importout.
lint/stylelint.config.mjs
// stylelint 17 with the standard SCSS configuration, plus rules that keep a codebase on the module system.
export default {
extends: ['stylelint-config-standard-scss'],
rules: {
// @use "tokens" as * hides where a member comes from.
'scss/at-use-no-unnamespaced': true,
// A private member that nothing reads is dead code.
'scss/no-unused-private-members': true,
// No Sass @import rules at all.
'scss/partial-no-import': true,
'at-rule-disallowed-list': ['import'],
},
}; src/_card.scss · lint/src/_card.scss
@use "tokens" as *;
@use "sass:map";
$-unused-gap: 4px;
.card {
border-radius: $radius;
font-size: map-get($sizes, md);
&:hover {
background: darken($brand, 10%);
}
} src/_legacy.scss · lint/src/_legacy.scss
@import "tokens";
.legacy {
color: $brand;
} src/_tokens.scss · lint/src/_tokens.scss
$brand: #0b5fff !default;
$radius: 6px !default;
$sizes: (sm: 0.875rem, md: 1rem); // Lints the SCSS files with stylelint and prints each problem as file:line:column, rule and message.
// Run it in this folder after `npm install` in the examples folder: node lint.mjs (or npx stylelint "src/**/*.scss")
import stylelint from 'stylelint';
const { results } = await stylelint.lint({ files: 'src/**/*.scss', configFile: 'stylelint.config.mjs' });
let count = 0;
for (const result of [...results].sort((a, b) => a.source.localeCompare(b.source))) {
const file = result.source.slice(result.source.lastIndexOf('/src/') + 1);
for (const w of [...result.warnings].sort((a, b) => a.line - b.line || a.column - b.column)) {
count++;
console.log(`${file}:${w.line}:${w.column} ${w.rule}\n ${w.text.replace(` (${w.rule})`, '')}`);
}
}
console.log(`\n${count} problems in ${results.length} files`);
process.exitCode = 0; Output
src/_card.scss:1:15 scss/at-use-no-unnamespaced Unexpected @use without namespace src/_card.scss:4:1 scss/no-unused-private-members Expected usage of private member "$-unused-gap" within the stylesheet src/_card.scss:8:14 scss/no-global-function-names Expected map.get instead of map-get src/_card.scss:11:17 scss/no-global-function-names Expected color.adjust($color, $lightness: -$amount) instead of darken($color, $amount) src/_legacy.scss:1:1 at-rule-disallowed-list Disallowed at-rule "@import" src/_legacy.scss:1:10 scss/partial-no-import Unexpected @import in a partial 6 problems in 3 files
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lint.mjs
The two tools overlap only in part. Sass warns about map-get() and darken() when it compiles a file that uses
them, and stylelint reports them in every file, compiled or not. Only the linter reports as * and unused private
members, which are valid Sass, and only the compiler sees what happens across files, such as an @extend without
a target. Run both in CI.
When native CSS is enough
Native CSS now has a counterpart for many Sass features, though rarely an equal one, and for some it has none. The support notes are the Baseline statuses of the web-features data:
| Job | Native CSS, and its support | Sass still needed? |
|---|---|---|
| Nesting | .card { .title {} } nests selectors; &__title is not valid CSS. Widely available: Chrome 120, Firefox 117, Safari 17.2 |
Only to build names, such as BEM classes |
| Variables | --gap takes part in the cascade and can change per element and at run time. Widely available for years |
No, for anything themed; $gap for build-time maths |
| Colour variants | color-mix() is computed by the browser and follows custom properties. Widely available: Chrome 111, Firefox 113, Safari 16.2 |
No, unless the colour must be a fixed value |
| Loops and maps | None | Yes: @each, @for and maps |
| Mixins | None in browsers yet | Yes |
| Modules | @import with layer(), without namespaces or private members |
Yes, for libraries |
| Functions | @function --name, run by the browser. Chromium only, from version 139 |
Yes; Sass 1.94.0 and later passes @function --name through as plain CSS |
| Conditions | if(). Chromium only, from version 137 |
Yes, or native if() with a fallback |
The script below compares one button written both ways. The SCSS version builds three variants and their hover
colours from a map. The native version uses nesting, one custom property per variant and color-mix(), and
page.html shows it under a parent that sets its own --btn-bg:
native/button.scss
// The button in SCSS: variants from a map, hover colours computed at build time.
@use "sass:color";
@use "sass:map";
$radius: 6px;
$variants: (primary: #0b5fff, success: #15803d, danger: #b91c1c);
.btn {
display: inline-flex;
padding: 0.5rem 1rem;
border: 0;
border-radius: $radius;
color: white;
@each $name, $bg in $variants {
&--#{$name} {
background: $bg;
&:hover {
background: color.mix(black, $bg, 15%);
}
}
}
&:focus-visible {
outline: 3px solid color.mix(white, map.get($variants, primary), 40%);
outline-offset: 2px;
}
} native/button.css
/* The button in native CSS: nesting, one custom property per variant, colours mixed by the browser. */
.btn {
display: inline-flex;
padding: 0.5rem 1rem;
border: 0;
border-radius: 6px;
color: white;
/* The fallback is the primary colour, so a parent can set --btn-bg too. */
background: var(--btn-bg, #0b5fff);
&:hover {
background: color-mix(in srgb, var(--btn-bg, #0b5fff), black 15%);
}
&:focus-visible {
outline: 3px solid color-mix(in srgb, #0b5fff, white 40%);
outline-offset: 2px;
}
}
.btn--success {
--btn-bg: #15803d;
}
.btn--danger {
--btn-bg: #b91c1c;
} native/page.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Two versions of one button</title>
<!-- Loaded once with the CSS compiled from button.scss and once with button.css. -->
<link rel="stylesheet" href="button.css">
<style>
.promo {
--btn-bg: #7c3aed;
}
</style>
</head>
<body>
<button class="btn btn--primary">Primary</button>
<button class="btn btn--success">Success</button>
<button class="btn btn--danger">Danger</button>
<div class="promo">
<button class="btn btn--primary">Promo</button>
</div>
</body>
</html> native/computed.json
{
"recordedWith": "Playwright 1.63.0",
"engines": [
"Chromium 153",
"Firefox 155",
"WebKit 26.6"
],
"page": "page.html, loaded once with the CSS compiled from button.scss and once with button.css",
"rows": [
{
"button": ".btn--primary",
"state": "normal",
"property": "background-color",
"scss": {
"Chromium 153": "#0b5fff",
"Firefox 155": "#0b5fff",
"WebKit 26.6": "#0b5fff"
},
"native": {
"Chromium 153": "#0b5fff",
"Firefox 155": "#0b5fff",
"WebKit 26.6": "#0b5fff"
}
},
{
"button": ".btn--primary",
"state": ":hover",
"property": "background-color",
"scss": {
"Chromium 153": "#0951d9",
"Firefox 155": "#0951d9",
"WebKit 26.6": "#0951d9"
},
"native": {
"Chromium 153": "#0951d9",
"Firefox 155": "#0951d9",
"WebKit 26.6": "#0951d9"
}
},
{
"button": ".btn--success",
"state": "normal",
"property": "background-color",
"scss": {
"Chromium 153": "#15803d",
"Firefox 155": "#15803d",
"WebKit 26.6": "#15803d"
},
"native": {
"Chromium 153": "#15803d",
"Firefox 155": "#15803d",
"WebKit 26.6": "#15803d"
}
},
{
"button": ".btn--success",
"state": ":hover",
"property": "background-color",
"scss": {
"Chromium 153": "#126d34",
"Firefox 155": "#126d34",
"WebKit 26.6": "#126d34"
},
"native": {
"Chromium 153": "#126d34",
"Firefox 155": "#126d34",
"WebKit 26.6": "#126d34"
}
},
{
"button": ".btn--danger",
"state": "normal",
"property": "background-color",
"scss": {
"Chromium 153": "#b91c1c",
"Firefox 155": "#b91c1c",
"WebKit 26.6": "#b91c1c"
},
"native": {
"Chromium 153": "#b91c1c",
"Firefox 155": "#b91c1c",
"WebKit 26.6": "#b91c1c"
}
},
{
"button": ".btn--danger",
"state": ":hover",
"property": "background-color",
"scss": {
"Chromium 153": "#9d1818",
"Firefox 155": "#9d1818",
"WebKit 26.6": "#9d1818"
},
"native": {
"Chromium 153": "#9d1818",
"Firefox 155": "#9d1818",
"WebKit 26.6": "#9d1818"
}
},
{
"button": ".btn--primary inside .promo",
"state": "normal",
"property": "background-color",
"scss": {
"Chromium 153": "#0b5fff",
"Firefox 155": "#0b5fff",
"WebKit 26.6": "#0b5fff"
},
"native": {
"Chromium 153": "#7c3aed",
"Firefox 155": "#7c3aed",
"WebKit 26.6": "#7c3aed"
}
},
{
"button": ".btn--primary inside .promo",
"state": ":hover",
"property": "background-color",
"scss": {
"Chromium 153": "#0951d9",
"Firefox 155": "#0951d9",
"WebKit 26.6": "#0951d9"
},
"native": {
"Chromium 153": "#6931c9",
"Firefox 155": "#6931c9",
"WebKit 26.6": "#6931c9"
}
},
{
"button": ".btn--primary",
"state": ":focus-visible",
"property": "outline-color",
"scss": {
"Chromium 153": "#6d9fff",
"Firefox 155": "#6d9fff",
"WebKit 26.6": "#6d9fff"
},
"native": {
"Chromium 153": "#6d9fff",
"Firefox 155": "#6d9fff",
"WebKit 26.6": "#6d9fff"
}
}
]
} // Compares the two versions of the button: what each one ships, and how large it is minified.
// Run it in this folder after `npm install` in the examples folder: node compare.mjs
import { readFileSync } from 'node:fs';
import { transform } from 'lightningcss';
import * as sass from 'sass';
const fromSass = sass.compile('button.scss', { style: 'expanded' }).css;
const native = readFileSync('button.css', 'utf8');
const minify = (css) => transform({ filename: 'x.css', code: Buffer.from(css), minify: true }).code.toString();
console.log(`=== What button.scss compiles to:\n${fromSass}\n`);
const rules = (css) => (minify(css).match(/\{/g) ?? []).length;
for (const [name, css] of [['button.scss, compiled', fromSass], ['button.css', native]]) {
console.log(`${name.padEnd(22)} ${String(minify(css).length).padStart(4)} bytes minified, ${rules(css)} rule blocks`);
}
// Is button.css plain CSS? Sass's CSS parser rejects every Sass-only feature.
try {
sass.compileString(native, { syntax: 'css' });
console.log('\nbutton.css parses as plain CSS: yes');
} catch (error) {
console.log(`\nbutton.css parses as plain CSS: no (${error.message.split('\n')[0]})`);
}
// Computed colours recorded in three browser engines (computed.json, recorded with Playwright on page.html).
const recorded = JSON.parse(readFileSync('computed.json', 'utf8'));
const same = recorded.rows.every((r) => new Set(Object.values(r.scss)).size === 1 && new Set(Object.values(r.native)).size === 1);
console.log(`\nComputed in ${recorded.engines.join(', ')} (${recorded.recordedWith}), ${same ? 'identical in all three' : 'differing between engines'}:`);
console.log(`${'button'.padEnd(28)} ${'state'.padEnd(15)} ${'button.scss'.padEnd(12)} button.css`);
for (const r of recorded.rows) {
const [scss, native] = [Object.values(r.scss)[0], Object.values(r.native)[0]];
console.log(`${r.button.padEnd(28)} ${r.state.padEnd(15)} ${scss.padEnd(12)} ${native}${scss === native ? '' : ' <- differs'}`);
} Output
=== What button.scss compiles to:
.btn {
display: inline-flex;
padding: 0.5rem 1rem;
border: 0;
border-radius: 6px;
color: white;
}
.btn--primary {
background: #0b5fff;
}
.btn--primary:hover {
background: rgb(3.6666666667%, 31.6666666667%, 85%);
}
.btn--success {
background: #15803d;
}
.btn--success:hover {
background: rgb(7%, 42.6666666667%, 20.3333333333%);
}
.btn--danger {
background: #b91c1c;
}
.btn--danger:hover {
background: rgb(61.6666666667%, 9.3333333333%, 9.3333333333%);
}
.btn:focus-visible {
outline: 3px solid rgb(42.5882352941%, 62.3529411765%, 100%);
outline-offset: 2px;
}
button.scss, compiled 360 bytes minified, 8 rule blocks
button.css 310 bytes minified, 5 rule blocks
button.css parses as plain CSS: yes
Computed in Chromium 153, Firefox 155, WebKit 26.6 (Playwright 1.63.0), identical in all three:
button state button.scss button.css
.btn--primary normal #0b5fff #0b5fff
.btn--primary :hover #0951d9 #0951d9
.btn--success normal #15803d #15803d
.btn--success :hover #126d34 #126d34
.btn--danger normal #b91c1c #b91c1c
.btn--danger :hover #9d1818 #9d1818
.btn--primary inside .promo normal #0b5fff #7c3aed <- differs
.btn--primary inside .promo :hover #0951d9 #6931c9 <- differs
.btn--primary :focus-visible #6d9fff #6d9fff
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compare.mjs
The native version is smaller once minified, 310 bytes against 360, and has five rule blocks against eight,
because one hover rule serves every variant: the browser mixes whatever --btn-bg is. The computed colours, recorded
with Playwright in all three engines, are identical for every variant, hover state and focus ring. The difference
is the last button: inside .promo, the native button took the parent’s purple and derived its own hover colour,
while the compiled SCSS cannot follow a value it never saw. One detail makes that work. The native .btn reads
var(--btn-bg, #0b5fff) and does not set --btn-bg on itself: a value set on the element would override the one
it inherits from .promo, and the parent’s theme would be ignored.
Vite’s own documentation recommends native CSS variables and plain, standards-based CSS for projects that target modern browsers. Tailwind CSS 4 goes further: its documentation says it is not designed to be used with Sass, Less or Stylus, because Tailwind does that job itself.
A decision rule
Three questions decide whether a job belongs to Sass or to native CSS
Text description of the diagram
The diagram asks three questions about one styling job, in order.
- Must the value change while the page runs, for example a theme, a state or a user preference? If yes, use native CSS: custom properties, color-mix() and nesting, because only the browser can recompute a value per element and at any time.
- If not: does the job generate many rules from data, such as loops over maps, utility classes or a token file? If yes, use Sass at build time: @each, @for and maps, often writing custom properties for the browser to use.
- If not: is it a library that others configure and load? If yes, use Sass modules with @use, @forward and !default settings.
If the answer to all three is no, write native CSS.
In practice the rule sorts a codebase into three layers:
- Sass at build time: design-token pipelines that turn maps into custom properties, generated utility classes,
and maths whose result is a fixed value. Nothing in the browser can replace
@eachover a map. - Sass modules: libraries that other teams configure and load, where
@use,@forward, private members and!defaultsettings give a real API. - Native CSS: component styles, states, themes and colour variants. Custom properties let a value change per element and while the page runs, which Sass variables never can.
A good split often has Sass write the custom properties and native CSS read them, as the exercise below does. Code in the middle of a migration is the exception: finish the move to modules first, then move components to native CSS one at a time, comparing computed styles as the button example did.
CSS Toolkit Turn a list of SCSS $name: value tokens into CSS custom properties for a :root rule.Exercise: keep Sass for the tokens, port the component
The exercise folder has a token generator in tokens/, written in Sass, and an alert component in
components/alert.scss that reads the token map directly:
components/alert.scss · exercise/components/alert.scss
// The alert component in SCSS. Port it to components/alert.css.
@use "sass:map";
@use "../tokens/palette";
.alert {
padding: palette.space(2) palette.space(3);
border-left: 4px solid map.get(palette.$colors, info);
color: map.get(palette.$colors, ink);
background: map.get(palette.$colors, surface);
a {
color: inherit;
font-weight: 600;
}
&__title {
margin: 0 0 palette.space(1);
font-weight: 600;
}
&--warning {
border-left-color: map.get(palette.$colors, warning);
}
&--danger {
border-left-color: map.get(palette.$colors, danger);
}
} tokens/_palette.scss · exercise/tokens/_palette.scss
// The token generator: the values, a spacing function and a mixin that writes custom properties.
$colors: (
info: #1d4ed8,
warning: #b45309,
danger: #b91c1c,
surface: #ffffff,
ink: #111827,
) !default;
$space-unit: 0.25rem !default;
// space(2) is 1rem with the default unit.
@function space($step) {
@return $space-unit * $step * 2;
}
// Writes one custom property per entry of $map: --<prefix>-<name>: <value>.
@mixin custom-properties($prefix, $map) {
@each $name, $value in $map {
--#{$prefix}-#{$name}: #{$value};
}
} tokens/tokens.scss · exercise/tokens/tokens.scss
// Compiles to tokens.css, the custom properties every component reads.
@use "palette";
:root {
@include palette.custom-properties(color, palette.$colors);
--space-1: #{palette.space(1)};
--space-2: #{palette.space(2)};
--space-3: #{palette.space(3)};
} - Write
components/alert.cssin native CSS. It must give exactly the declarations the compiledalert.scssgives, with the same selectors, and take every colour and space from the custom properties thattokens.scsswrites. - Keep the token generator in Sass, and write
test/palette.test.scsswith at least two True tests for it: one forspace()and one for the output ofcustom-properties.
Then run node check.mjs in the exercise folder. The starter folder passes only the token test, so four of the five
checks fail:
// Sample tests for the exercise: the token generator, the native alert.css and your True tests.
// Run it in this folder after `npm install` in the examples folder: node check.mjs
import { existsSync, readFileSync } from 'node:fs';
import { transform } from 'lightningcss';
import * as sass from 'sass';
import sassTrue from 'sass-true';
const EXPECTED = ['--color-info', '--color-warning', '--color-danger', '--color-surface', '--color-ink', '--space-1', '--space-2', '--space-3'];
const tokens = sass.compile('tokens/tokens.scss').css;
const props = Object.fromEntries([...tokens.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
/** Replaces var(--name) and var(--name, fallback) with the values tokens.css defines. */
const resolve = (css) => css.replace(/var\((--[\w-]+)(?:,\s*([^()]+))?\)/g, (all, name, fallback) => props[name] ?? fallback ?? all);
/** Every declaration as "selector { property: value }", with nesting expanded and values written one way. */
function declarations(css) {
// Targets without CSS nesting make Lightning CSS expand it; minifying writes every value the same way.
const flat = transform({ filename: 'alert.css', code: Buffer.from(css), minify: true, targets: { chrome: 100 << 16 } }).code.toString();
const out = [];
for (const [, selector, body] of flat.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
for (const d of body.split(';').filter(Boolean)) out.push(`${selector} { ${d} }`);
}
return out.sort();
}
/** True's Sass tests, run with a describe() and it() of our own instead of a test runner's. */
function trueTests(file) {
const results = [];
const names = [];
const describe = (name, body) => {
names.push(name);
body();
names.pop();
};
const it = (name, body) => {
try {
body();
results.push({ name: [...names, name].join(' > '), ok: true });
} catch (error) {
results.push({ name: [...names, name].join(' > '), ok: false, why: error.message.split('\n')[0] });
}
};
sassTrue.runSass({ describe, it }, file);
return results;
}
const native = existsSync('components/alert.css') ? readFileSync('components/alert.css', 'utf8') : null;
let plain = false;
let plainWhy = 'components/alert.css does not exist';
if (native !== null) {
try {
sass.compileString(native, { syntax: 'css' });
plain = true;
} catch (error) {
plainWhy = error.message.split('\n')[0];
}
}
const literals = native === null ? [] : native.replace(/\/\*[\s\S]*?\*\//g, '').match(/#[0-9a-f]{3,8}\b|\b(?:rgb|hsl|oklch|lab)a?\(/gi) ?? [];
let same = false;
let sameWhy = 'nothing to compare';
if (plain) {
const want = declarations(sass.compile('components/alert.scss').css);
const got = declarations(resolve(native));
const missing = want.filter((d) => !got.includes(d));
const extra = got.filter((d) => !want.includes(d));
same = !missing.length && !extra.length;
sameWhy = `${missing.length ? `missing ${missing[0]}` : ''}${missing.length && extra.length ? '; ' : ''}${extra.length ? `unexpected ${extra[0]}` : ''}`;
}
let tests = [];
let testsWhy = 'test/palette.test.scss does not exist';
if (existsSync('test/palette.test.scss')) {
try {
tests = trueTests('test/palette.test.scss');
testsWhy = tests.length < 2 ? `${tests.length} test found` : tests.filter((t) => !t.ok).map((t) => `${t.name}: ${t.why}`).join('; ');
} catch (error) {
testsWhy = error.message.split('\n')[0];
}
}
const results = [
['tokens/tokens.scss writes the eight custom properties', EXPECTED.every((p) => p in props), `missing ${EXPECTED.filter((p) => !(p in props)).join(', ')}`],
['components/alert.css is plain CSS', plain, plainWhy],
['alert.css takes every colour from a custom property', native !== null && literals.length === 0, native === null ? 'no file' : `found ${literals.join(', ')}`],
['alert.css gives the same declarations as alert.scss', same, sameWhy],
['test/palette.test.scss has two or more passing True tests', tests.length >= 2 && tests.every((t) => t.ok), testsWhy],
];
let failed = 0;
for (const [name, ok, why] of results) {
if (!ok) failed++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` (${why})`}`);
}
console.log(`\n${results.length - failed} of ${results.length} tests pass`);
process.exitCode = failed ? 1 : 0; Output (exit status 1)
PASS tokens/tokens.scss writes the eight custom properties FAIL components/alert.css is plain CSS (components/alert.css does not exist) FAIL alert.css takes every colour from a custom property (no file) FAIL alert.css gives the same declarations as alert.scss (nothing to compare) FAIL test/palette.test.scss has two or more passing True tests (test/palette.test.scss does not exist) 1 of 5 tests pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node check.mjs
First hint: native nesting combines selectors but never joins names, so ask which of the five selectors of the
compiled alert.scss can stay nested inside .alert and which must be written out in full.
Self-check
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
When would you still choose Sass over native CSS today?
When the work happens best at build time or needs a real module API. Sass loops and maps generate many rules from
data, such as utility classes or a design-token file that turns a map into custom properties, and native CSS has
no loops. Sass modules give a library namespaces, private members and !default settings that other teams
configure, which CSS @import cannot offer. Sass also computes values once, so maths and colour adjustments become
fixed numbers that work in every browser. For everything that must change per element or while the page runs,
such as themes, states and user preferences, native custom properties, nesting and color-mix() are the better
tools and are widely available. CSS if() and @function exist too, but only in Chromium browsers, so Sass or a
fallback is still needed for them.
Key takeaways
- Vite compiles
.scsswithsass-embeddedwhen it is installed, otherwise withsass; pass Sass options throughcss.preprocessorOptions.scss. - In Vite 8.3.4, CSS source maps come from the development server (
css.devSourcemap);build.sourcemapmaps only the JavaScript. - Through the asynchronous API that Vite uses,
sass-embeddedcompiled Bootstrap several times as fast assass. - Pin the Sass version; an upgrade from 1.78.0 to 1.105.1 rewrote 127 colour lines of a customised Bootstrap.
- Test functions and mixins with True through your test runner, and lint with stylelint-scss plus the module rules.
- Native CSS covers nesting, variables and colour variants; Sass keeps loops, maps, mixins and library modules.
- Native nesting does not join names: write BEM classes such as
.alert__titleout in full. - Read theme values with
var(--name, fallback)and do not set them on the component itself, so parents can theme it.
References
- Vite: shared options (css.preprocessorOptions, css.devSourcemap) (Vite team (vite.dev))
- Vite: build options (build.sourcemap, build.cssMinify) (Vite team (vite.dev))
- Vite: features, CSS pre-processors (Vite team (vite.dev))
- Dart Sass: the sass and sass-embedded packages (Sass team (sass-lang.com))
- JavaScript API: Options (charset, quietDeps, fatalDeprecations) (Sass team (sass-lang.com))
- True: Sass unit tests (OddBird (GitHub))
- stylelint-scss: rules (stylelint-scss (GitHub))
- stylelint-config-standard-scss (stylelint-scss (GitHub))
- Nesting (Baseline status and browser versions) (W3C WebDX Community Group)
- color-mix() (Baseline status and browser versions) (W3C WebDX Community Group)
- if() (Baseline status and browser versions) (W3C WebDX Community Group)
- @function (Baseline status and browser versions) (W3C WebDX Community Group)
- Tailwind CSS: compatibility with Sass, Less and Stylus (Tailwind Labs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress