Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Node.js 24.21.0, Dart Sass 1.105.1 and Vite 8.3.4
  • By MySmartCoPilot
Free: 30 minutes

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.scss passes options to Sass’s JavaScript API for every file: quietDeps, fatalDeprecations, silenceDeprecations, loadPaths and importers all work there, so the CI rules from the previous lesson can live in the build itself. With the config below, adding margin: (8px / 2) to app.scss made vite build fail with the slash-div error.
  • additionalData prepends 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.devSourcemap turns on CSS source maps in the development server, so the browser’s developer tools show the .scss file and line of each rule.
A Vite app with one Sass stylesheet

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"
  }
}
Building the app, and the source maps of each mode JavaScript · vite-app/build.mjs
// 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:

Compile times for all of Bootstrap JavaScript · speed/bench.mjs
// 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:

A customised Bootstrap

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 });
}
The same stylesheet with Sass 1.78.0 and 1.105.1 JavaScript · upgrade/upgrade.mjs
// 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.

Text Diff Compare the expanded CSS from before and after a Sass upgrade, line by line.

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.

Five Sass tests and the file that runs them in Vitest

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'],
  },
});
The suite as it is, then after a careless edit JavaScript · testing/run-tests.mjs
// 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-unnamespaced reports @use … as *;
  • scss/no-unused-private-members reports private members that nothing reads;
  • scss/partial-no-import and the core rule at-rule-disallowed-list keep @import out.
A stylelint configuration and three partials

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);
The problems stylelint reports JavaScript · lint/lint.mjs
// 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:

One button, twice

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"
      }
    }
  ]
}
What each version ships, and what browsers computed JavaScript · native/compare.mjs
// 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

Decision flow: values that change at run time go to native CSS; generated rules and configurable libraries stay in Sass.Must the value change whilethe page runs? (theme, state,user preference)Does it generate many rulesfrom data? (loops over maps,utility classes, token files)Is it a library that othersconfigure and load?Native CSS:custom properties,color-mix(), nestingSass at build time:@each, @for, maps,writing custom propertiesSass modules:@use, @forward, !defaultNative CSSyesnoyesnoyesno

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.

  1. 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.
  2. 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.
  3. 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 @each over a map.
  • Sass modules: libraries that other teams configure and load, where @use, @forward, private members and !default settings 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:

The token generator and the component

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)};
}
  1. Write components/alert.css in native CSS. It must give exactly the declarations the compiled alert.scss gives, with the same selectors, and take every colour and space from the custom properties that tokens.scss writes.
  2. Keep the token generator in Sass, and write test/palette.test.scss with at least two True tests for it: one for space() and one for the output of custom-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:

The sample tests, run on the folder as given JavaScript · exercise/check.mjs
// 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.

  1. Question 1 of 7 A Vite 8 project has both sass and sass-embedded installed. Which one compiles its .scss files?

    Choose one answer.

    Show the answer to question 1

    Answer: sass-embedded, unless it cannot be loaded on that computer

    Vite tries sass-embedded first and falls back to sass when it is missing or its native program cannot run. No plugin is needed, only the package.

  2. Question 2 of 7 In the recorded Vite 8.3.4 build with build.sourcemap: true and css.devSourcemap: true, where did CSS source maps exist?

    Choose one answer.

    Show the answer to question 2

    Answer: Only in the development server, inline in the served CSS

    The build wrote a map for the JavaScript and none for the CSS. The development server's CSS carried an inline map whose sources were the two .scss files.

  3. Question 3 of 7 After an upgrade from Sass 1.78.0 to 1.105.1, #062f2c became rgb(2.3529411765%, 18.5098039216%, 17.2549019608%) in the CSS. What happened?

    Choose one answer.

    Show the answer to question 3

    Answer: The colour is the same mix; the new version keeps its fractional channels instead of rounding to hex

    Bootstrap mixes this colour, and the mix has a green channel of 47.2. Sass 1.78.0 rounded it to 47 to write hex; 1.105.1 writes the exact value as rgb() percentages. Hex colours written in the source stay hex.

  4. Question 4 of 7 What does this True test check?

    Read the code, then choose one answer.

    @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;
        }
      }
    }
    Show the answer to question 4

    Answer: That the CSS the mixin writes equals the CSS in the expect block

    An assert block compares the CSS produced inside output with the CSS written inside expect. Values are compared with assert-equal instead.

  5. Question 5 of 7 Which of these does only the linter report, not the Sass compiler?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • @use "tokens" as *;
    • A private member that nothing reads

    as * and unused private members are valid Sass, so only stylelint-scss reports them. The compiler already warns about global functions such as map-get() and about @import.

  6. Question 6 of 7 You port .alert { &__title { font-weight: 600; } } from SCSS to native CSS. What do you write?

    Choose one answer.

    Show the answer to question 6

    Answer: .alert__title { font-weight: 600; }, outside the .alert rule

    Native nesting combines selectors but never joins names, so a BEM class must be written in full. & .alert__title would mean a title inside an alert, a descendant selector with a different specificity.

  7. Question 7 of 7 A native button writes .btn { --btn-bg: #0b5fff; background: var(--btn-bg); }, and a parent sets .promo { --btn-bg: #7c3aed; }. What colour is a button inside .promo?

    Choose one answer.

    Show the answer to question 7

    Answer: #0b5fff: the value set on the button itself beats the one it would inherit

    A custom property declared on an element applies to that element; inheritance only fills in when nothing is declared. Reading var(--btn-bg, #0b5fff) without setting --btn-bg on .btn lets the parent theme it.

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 .scss with sass-embedded when it is installed, otherwise with sass; pass Sass options through css.preprocessorOptions.scss.
  • In Vite 8.3.4, CSS source maps come from the development server (css.devSourcemap); build.sourcemap maps only the JavaScript.
  • Through the asynchronous API that Vite uses, sass-embedded compiled Bootstrap several times as fast as sass.
  • 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__title out in full.
  • Read theme values with var(--name, fallback) and do not set them on the component itself, so parents can theme it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.