CSS Module 19 – Sass modules, migrating off @import and Sass in production
Migrating Sass from @import to modules
Move a Sass codebase off @import: read deprecation warnings, run sass-migrator step by step, prove the CSS is unchanged, and fix what it cannot.
What you will learn
- Read Dart Sass deprecation warnings and tell which come from your files and which from dependencies
- Migrate a codebase with sass-migrator (module, division, color and if-function) and prove the CSS is unchanged
- Handle what the migrator can't, such as nested imports, globals shared across files and frameworks that use @import
Before you start
What you’ll learn · Before you start
What you will learn
- Read Dart Sass deprecation warnings and tell which come from your files and which from dependencies
- Migrate a codebase with sass-migrator (module, division, color and if-function) and prove the CSS is unchanged
- Handle what the migrator can't, such as nested imports, globals shared across files and frameworks that use @import
Before you start
On this page
Migrating off @import means replacing every Sass @import with @use or @forward, and every global
built-in function, such as map-get() or darken(), with its module version, such as map.get() or
color.adjust(). Dart Sass 1.80.0 deprecated both, so every compile of an old codebase now prints warnings. Sass
plans to remove them in Dart Sass 3.0.0, which it says will come no sooner than two years after 1.80.0. Dart Sass
2.0.0 comes first and removes other deprecated features, among them / as division and the old colour functions,
so a codebase that waits for 3.0.0 breaks earlier. A safe migration has four parts: read the warnings to find the
ones that are yours, run sass-migrator one migration at a time, prove after each step that the compiled CSS is
byte-identical, and fix by hand the few patterns the migrator gets wrong. The examples below do all four with Dart
Sass 1.105.1, sass-migrator 2.6.1 and Bootstrap 5.3.8, with each recorded output under its example.
What is deprecated, and when it breaks
Each deprecation has an ID. It appears in square brackets in every warning, and the command-line flags and the JavaScript API options take it. These five are the ones an old codebase meets most:
| ID | What it covers, and the migration that fixes it | Deprecated, then removed in |
|---|---|---|
import |
Sass @import rules: module |
1.80.0, then 3.0.0 |
global-builtin |
global functions that have module versions, such as map-get(), percentage() and unquote(): module |
1.80.0, then 3.0.0 |
slash-div |
/ as division outside calc(): division |
1.33.0, then 2.0.0 |
color-functions |
darken(), lighten(), adjust-hue() and channel functions such as red(): color |
1.79.0, then 2.0.0 |
if-function |
the legacy if($condition, $if-true, $if-false): if-function |
1.95.0; removal not announced yet |
At Dart Sass 1.105.1 neither 2.0.0 nor 3.0.0 has been released. When @import does go, Sass says, every
@import will be treated as a plain CSS import, probably after a period in which old Sass imports are errors.
Plain CSS @import url(…) rules are not affected, and neither is @import in .css files.
Reading the warnings
A deprecation warning names its ID, says what to use instead, links to the documentation, and ends with a stack:
the file and line where the deprecated feature was used, then each load that led there, down to the root
stylesheet. The stack is how you tell your code from a dependency’s. The site below still loads Bootstrap with
@import, configures it with variables set before the import, and adds one rule with three deprecated features of
its own. The script counts every warning by type and origin, then shows what the command line prints:
warnings/site.scss
// The site: Bootstrap with its own colours, still loaded with @import.
$primary: #0f766e;
$enable-shadows: true;
@import "bootstrap/scss/bootstrap";
.hero {
max-width: map-get($container-max-widths, lg);
padding: $spacer * 3;
border-radius: $border-radius / 2;
background: tint-color($primary, 80%);
} 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 });
} 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-migrator": "2.6.1"
}
} // Compiles a site that still loads Bootstrap with @import, and reports where its deprecation warnings come from.
// Run it in this folder after `npm install` in the examples folder: node report.mjs
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as sass from 'sass';
import { installBootstrap } from '../vendor.mjs';
installBootstrap('.');
// The command-line program of the sass package (the same one `npx sass` runs).
const SASS = join(dirname(createRequire(import.meta.url).resolve('sass')), 'sass.js');
// 1. Every warning (verbose), sorted by type and by origin: your files, or files reached through a load path.
const table = {};
sass.compile('site.scss', {
loadPaths: ['node_modules'],
verbose: true,
logger: {
warn(_message, { deprecationType, span }) {
const type = deprecationType?.id ?? 'other';
const file = span?.url ? fileURLToPath(span.url) : '';
const origin = file.includes('/node_modules/') ? 'dependency' : 'yours';
table[type] ??= { yours: 0, dependency: 0 };
table[type][origin]++;
},
},
});
console.log('type yours dependency');
for (const [type, n] of Object.entries(table).sort()) console.log(`${type.padEnd(16)} ${String(n.yours).padStart(6)} ${String(n.dependency).padStart(11)}`);
// 2. What the command line prints by default, and with --quiet-deps.
const cli = (...flags) => spawnSync(process.execPath, [SASS, '--load-path=node_modules', '--no-source-map', ...flags, 'site.scss', 'site.css'], { encoding: 'utf8' }).stderr;
const terse = cli();
const blocks = terse.split(/\n(?=DEPRECATION WARNING|WARNING:)/);
console.log(`\n$ sass --load-path=node_modules site.scss site.css\n${blocks.filter((b) => b.startsWith('DEPRECATION')).length} warnings printed, then:\n${blocks.find((b) => b.startsWith('WARNING:'))?.trim()}`);
console.log(`\nThe first one in full:\n${blocks[0].trim()}`);
const quiet = cli('--quiet-deps').split(/\n(?=DEPRECATION WARNING)/).filter((b) => b.startsWith('DEPRECATION'));
console.log(`\n$ sass --load-path=node_modules --quiet-deps site.scss site.css\n${quiet.length} warnings printed:`);
for (const b of quiet) console.log(` ${b.split('\n')[0]}\n at ${b.trim().split('\n').at(-1).trim()}`); Output
type yours dependency
color-functions 0 6
global-builtin 1 156
if-function 0 83
import 1 87
slash-div 1 0
$ sass --load-path=node_modules site.scss site.css
21 warnings printed, then:
WARNING: 314 repetitive deprecation warnings omitted.
Run in verbose mode to see all warnings.
The first one in full:
DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
╷
5 │ @import "bootstrap/scss/bootstrap";
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^
╵
site.scss 5:9 root stylesheet
$ sass --load-path=node_modules --quiet-deps site.scss site.css
3 warnings printed:
DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
at site.scss 5:9 root stylesheet
DEPRECATION WARNING [global-builtin]: Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
at site.scss 8:14 root stylesheet
DEPRECATION WARNING [slash-div]: Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.
at site.scss 10:18 root stylesheet
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node report.mjs
Of 335 warnings, 3 come from site.scss and 332 from Bootstrap’s files. Two settings shape what you see:
- Terse mode, the default, prints each deprecation type five times and then one summary line. Here that was 21
warnings and “314 repetitive deprecation warnings omitted”.
--verbose(orverbose: truein the JavaScript API) prints all of them, which helps when counting the work left. The JavaScript API counts per compilation, so a build tool that compiles 40 entry files can print five of each type 40 times. --quiet-deps(quietDeps: true) hides warnings from dependencies: files reached through a load path or, in the JavaScript API, through an importer. Files loaded by relative URLs from the entry point are never dependencies. With it, exactly the three warnings insite.scssremain, each pointing at its line.
Two more flags take deprecation IDs. --silence-deprecation=import hides one type everywhere, yours included, so
keep it for short periods. --fatal-deprecation=import turns a type into an error, which is how you keep a
migrated codebase migrated. vendor.mjs only copies Bootstrap’s sources into the example’s own folder, so that
every path reads as it would in a project.
Migrating with sass-migrator
sass-migrator rewrites stylesheets for you, one kind of change per command. Install it with
npm install --save-dev sass-migrator (or as a standalone program) and run sass-migrator <migration> <entry>.
Three options matter for every run:
--migrate-deps(-d) also migrates every stylesheet the entry point loads, except those found through a load path, which the migrator treats as third-party code and leaves alone;--load-path(-I) tells it where to find those dependencies, as the compiler’s option does;--dry-run --verbose(-nv) prints the new files instead of writing them.
The module migration is the large one. It turns @import into @use, adds namespaces wherever a member from
another file is used, adds a @use to files that used members without loading them, turns variables set before an
import into a with clause, replaces global built-in functions with their sass: module versions, and drops a
leading - or _ from members used across files, since those would now be private. The project below has a token
partial, a helpers partial and a button, all joined by @import. The script compiles it, runs four migrations in
order, prints the diff of every file each one changed, and compiles again after each:
legacy/style.scss
// The site's entry point.
$brand: #0f766e;
@import "tokens";
@import "helpers";
@import "button"; legacy/_tokens.scss
// Design tokens: every value the components share.
$brand: #2563eb !default;
$radius: 6px !default;
$gap: 16px !default;
$sizes: (sm: 0.875rem, md: 1rem, lg: 1.25rem); legacy/_helpers.scss
// Helpers used by every component.
@function half($value) {
@return $value / 2;
}
@mixin focus-ring($color) {
outline: 2px solid lighten($color, 20%);
outline-offset: 2px;
} legacy/_button.scss
// The button component.
.btn {
display: inline-block;
padding: half($gap) $gap;
border-radius: $radius;
background: $brand;
color: white;
font-size: map-get($sizes, md);
&:hover {
background: darken($brand, 8%);
}
&:focus-visible {
@include focus-ring($brand);
}
&--wide {
width: percentage(1/3);
}
} // Migrates the legacy project to the module system with sass-migrator, one migration at a time, and proves after
// each step that the CSS has not changed. Run it in this folder after `npm install`: node migrate.mjs
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { cpSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import * as sass from 'sass';
const MIGRATOR = createRequire(import.meta.url).resolve('sass-migrator/sass-migrator.js');
// Work on a copy, so legacy/ keeps the original files to compare with.
rmSync('project', { recursive: true, force: true });
cpSync('legacy', 'project', { recursive: true });
/** Compiles the project and counts its deprecation warnings by type. */
function compile() {
const warnings = {};
const { css } = sass.compile('project/style.scss', {
verbose: true,
logger: { warn: (_message, { deprecationType }) => { const id = deprecationType?.id ?? 'other'; warnings[id] = (warnings[id] ?? 0) + 1; } },
});
const total = Object.values(warnings).reduce((a, b) => a + b, 0);
const types = Object.entries(warnings).map(([id, n]) => `${id} ${n}`).join(', ');
return { css, summary: `${total} deprecation warnings${total ? ` (${types})` : ''}` };
}
/** The changed lines of two versions of a file, with one line of context (a small LCS diff). */
function diff(before, after) {
const a = before.split('\n');
const b = after.split('\n');
const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
const lines = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
lines.push([' ', a[i++]]);
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) lines.push(['-', a[i++]]);
else lines.push(['+', b[j++]]);
}
while (i < a.length) lines.push(['-', a[i++]]);
while (j < b.length) lines.push(['+', b[j++]]);
const changed = (k) => lines[k] !== undefined && lines[k][0] !== ' ';
const kept = lines.map((l, k) => k).filter((k) => changed(k) || changed(k - 1) || changed(k + 1));
return kept.map((k, n) => `${n && k > kept[n - 1] + 1 ? ' …\n' : ''}${lines[k][0]} ${lines[k][1]}`.trimEnd()).join('\n');
}
const files = () => readdirSync('project').filter((f) => f.endsWith('.scss')).sort();
const snapshot = () => Object.fromEntries(files().map((f) => [f, readFileSync(`project/${f}`, 'utf8')]));
const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
const baseline = compile();
console.log(`Before: ${baseline.summary}; CSS ${baseline.css.length} bytes, sha256 ${sha(baseline.css)}`);
for (const migration of ['module', 'division', 'color', 'if-function']) {
const before = snapshot();
const run = spawnSync(process.execPath, [MIGRATOR, migration, '--migrate-deps', 'style.scss'], { cwd: 'project', encoding: 'utf8' });
console.log(`\n$ sass-migrator ${migration} --migrate-deps style.scss (exit ${run.status})`);
const said = `${run.stdout}${run.stderr}`.trim();
if (said) console.log(said);
for (const [file, text] of Object.entries(snapshot())) {
if (text !== before[file]) console.log(`--- ${file}\n${diff(before[file], text)}`);
}
const now = compile();
console.log(`After ${migration}: ${now.summary}; CSS ${now.css === baseline.css ? 'byte-identical' : 'CHANGED'} (sha256 ${sha(now.css)})`);
} Output
Before: 11 deprecation warnings (import 3, slash-div 2, global-builtin 4, color-functions 2); CSS 374 bytes, sha256 30144ee30bbc
$ sass-migrator module --migrate-deps style.scss (exit 0)
--- _button.scss
+ @use "sass:color";
+ @use "sass:map";
+ @use "sass:math";
+ @use "helpers";
+ @use "tokens";
+
// The button component.
…
display: inline-block;
- padding: half($gap) $gap;
- border-radius: $radius;
- background: $brand;
+ padding: helpers.half(tokens.$gap) tokens.$gap;
+ border-radius: tokens.$radius;
+ background: tokens.$brand;
color: white;
- font-size: map-get($sizes, md);
+ font-size: map.get(tokens.$sizes, md);
&:hover {
- background: darken($brand, 8%);
+ background: color.adjust(tokens.$brand, $lightness: -8%);
}
…
&:focus-visible {
- @include focus-ring($brand);
+ @include helpers.focus-ring(tokens.$brand);
}
…
&--wide {
- width: percentage(1/3);
+ width: math.percentage(1/3);
}
--- _helpers.scss
+ @use "sass:color";
// Helpers used by every component.
…
@mixin focus-ring($color) {
- outline: 2px solid lighten($color, 20%);
+ outline: 2px solid color.adjust($color, $lightness: 20%);
outline-offset: 2px;
--- style.scss
// The site's entry point.
- $brand: #0f766e;
- @import "tokens";
- @import "helpers";
- @import "button";
+ @use "tokens" with ($brand: #0f766e);
+ @use "helpers";
+ @use "button";
After module: 2 deprecation warnings (slash-div 2); CSS byte-identical (sha256 30144ee30bbc)
$ sass-migrator division --migrate-deps style.scss (exit 0)
--- _button.scss
&--wide {
- width: math.percentage(1/3);
+ width: math.percentage(math.div(1, 3));
}
--- _helpers.scss
@function half($value) {
- @return $value / 2;
+ @return $value * 0.5;
}
After division: 0 deprecation warnings; CSS byte-identical (sha256 30144ee30bbc)
$ sass-migrator color --migrate-deps style.scss (exit 0)
Nothing to migrate!
After color: 0 deprecation warnings; CSS byte-identical (sha256 30144ee30bbc)
$ sass-migrator if-function --migrate-deps style.scss (exit 0)
Nothing to migrate!
After if-function: 0 deprecation warnings; CSS byte-identical (sha256 30144ee30bbc)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node migrate.mjs
Migrate in small steps and compare the CSS with the baseline after each one
Text description of the diagram
The diagram shows the order of a migration from @import to the module system, top to bottom.
- Baseline: compile the project as it is, keep the CSS, and count the deprecation warnings by type.
- Run sass-migrator module with --migrate-deps: @import rules become @use rules, members get namespaces, and variables set before an import become a with clause.
- Run the division, color and if-function migrations.
- Fix by hand what the migrator cannot: nested imports whose rules use & with a suffix, and frameworks that still use @import.
- Guard the result in CI with --quiet-deps and --fatal-deprecation, so a deprecated feature in your own files fails the build.
After steps 2, 3 and 4 an arrow leads to the same check: is the compiled CSS byte-identical to the baseline?
The module run did most of the work. style.scss lost its global variable: $brand: #0f766e now configures the
tokens through @use "tokens" with (…), which works because _tokens.scss declared $brand with !default.
_button.scss gained five @use rules, two for the partials it had silently relied on and three for built-in
modules, and every member got its namespace. darken($brand, 8%) became
color.adjust(tokens.$brand, $lightness: -8%), the exact equivalent, which is why the CSS did not change. The
warnings fell from 11 to 2, both / divisions. The division run fixed those, writing math.div(1, 3) and turning
$value / 2 into $value * 0.5, and the last two runs found nothing to do. After every step the CSS kept the
same SHA-256 hash: 374 bytes, unchanged.
Proving that nothing changed
A migration should change the source and nothing else, so compare the compiled CSS byte for byte, not by eye:
- Pin the Sass version for the whole migration. A new Sass release may write the same colour differently, which would hide a real change among harmless ones.
- Compile every entry point before you start and keep the files as the baseline.
- After each migration, compile again and compare: a hash,
cmp, or a diff of the two files. - When the bytes differ, read the diff before you continue. Compressed output is easier to compare once it is expanded again.
Compare the same output style on both sides: compressed output does more than remove whitespace, and writes
#ffffff as #fff and 0.5px as .5px. Use the text diff tool below to compare two expanded files side by side.
What the migrator cannot do
Nested imports
@import may stand inside a style rule, where the imported rules are nested under it. @use may not, because a
module’s CSS is included once and could not also appear in a nested position. The module migration converts a
nested import into meta.load-css(), which includes the module’s CSS where it is called. That works for plain
rules, but meta.load-css() compiles the module on its own before nesting it, so a parent selector & inside the
module no longer refers to the outer rule. BEM partials that write &__title break:
nested/sidebar.scss
.sidebar {
@import "widget";
} nested/_widget.scss
// Plain rules, written to be imported inside another rule.
.title {
font-weight: 600;
}
.body {
margin: 0;
} nested/card.scss
.card {
@import "card-parts";
} nested/_card-parts.scss
// BEM parts: & means "the rule this file is imported into".
&__title {
font-weight: 600;
}
&__body {
margin: 0;
} nested/_card-parts-mixin.scss
// The same parts wrapped in a mixin, so & is resolved where the mixin is included.
@mixin parts {
&__title {
font-weight: 600;
}
&__body {
margin: 0;
}
} nested/card-mixin.scss
@use "card-parts-mixin";
.card {
@include card-parts-mixin.parts;
} // Two nested @imports, what sass-migrator makes of them, and the mixin that fixes the one it breaks.
// Run it in this folder after `npm install` in the examples folder: node nested.mjs
import { spawnSync } from 'node:child_process';
import { copyFileSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import * as sass from 'sass';
const MIGRATOR = createRequire(import.meta.url).resolve('sass-migrator/sass-migrator.js');
const compile = (file) => {
try {
return sass.compile(file, { logger: sass.Logger.silent }).css;
} catch (error) {
return `Error: ${error.message.split('\n')[0]}\n${error.message.trim().split('\n').at(-1).trim()}`;
}
};
for (const name of ['sidebar', 'card']) {
const before = compile(`${name}.scss`);
copyFileSync(`${name}.scss`, `${name}-migrated.scss`);
spawnSync(process.execPath, [MIGRATOR, 'module', `${name}-migrated.scss`], { encoding: 'utf8' });
console.log(`=== ${name}.scss with @import:\n${before}\n`);
console.log(`--- after sass-migrator module:\n${readFileSync(`${name}-migrated.scss`, 'utf8').trim()}\n`);
console.log(`--- which compiles to:\n${compile(`${name}-migrated.scss`)}\n`);
}
console.log(`=== card-mixin.scss:\n${compile('card-mixin.scss')}`); Output
=== sidebar.scss with @import:
.sidebar .title {
font-weight: 600;
}
.sidebar .body {
margin: 0;
}
--- after sass-migrator module:
@use "sass:meta";
.sidebar {
@include meta.load-css("widget");
}
--- which compiles to:
.sidebar .title {
font-weight: 600;
}
.sidebar .body {
margin: 0;
}
=== card.scss with @import:
.card__title {
font-weight: 600;
}
.card__body {
margin: 0;
}
--- after sass-migrator module:
@use "sass:meta";
.card {
@include meta.load-css("card-parts");
}
--- which compiles to:
Error: A top-level selector may not contain a parent selector with a suffix.
card-migrated.scss 3:3 root stylesheet
=== card-mixin.scss:
.card__title {
font-weight: 600;
}
.card__body {
margin: 0;
}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node nested.mjs
The sidebar came through unchanged, while the migrated card stopped with “A top-level selector may not contain a
parent selector with a suffix”. The Sass documentation recommends the fix that works in both cases: wrap the
partial’s rules in a mixin and @include it where the import stood. The mixin’s rules are resolved where it is
included, so &__title becomes .card__title again. Keep meta.load-css() for files you cannot edit, and check
them for & first.
@extend across files
With @import, an @extend reaches every matching selector in the whole compilation. In the module system an
extension is scoped: it applies only to the module that writes it and the modules it loads, directly or through
others. A partial that extends a selector defined in a file it never loads breaks after the migration, and the
migrator does not add the missing @use, because no member of that file is used:
extend/main.scss
@import "base";
@import "link"; extend/_base.scss
// Shared base styles.
.btn {
padding: 4px 12px;
} extend/_link.scss
// Relies on .btn existing somewhere in the compilation.
.link-button {
@extend .btn;
color: #1d4ed8;
} // An @extend across files before and after the module migration, and the one-line fix.
// Run it in this folder after `npm install` in the examples folder: node extend.mjs
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import * as sass from 'sass';
const MIGRATOR = createRequire(import.meta.url).resolve('sass-migrator/sass-migrator.js');
const compile = (file) => {
try {
return sass.compile(file, { logger: sass.Logger.silent }).css;
} catch (error) {
return `Error: ${error.message.split('\n').slice(0, 2).join('\n')}`;
}
};
// Work on a copy, so the files above stay as they are.
rmSync('project', { recursive: true, force: true });
mkdirSync('project');
for (const file of ['main.scss', '_base.scss', '_link.scss']) copyFileSync(file, `project/${file}`);
const before = compile('project/main.scss');
console.log(`=== With @import:\n${before}\n`);
spawnSync(process.execPath, [MIGRATOR, 'module', '--migrate-deps', 'main.scss'], { cwd: 'project' });
console.log(`=== After sass-migrator module, main.scss:\n${readFileSync('project/main.scss', 'utf8').trim()}\n`);
console.log(`=== Compiled:\n${compile('project/main.scss')}\n`);
// The fix: the extending module loads the module that defines the selector it extends.
writeFileSync('project/_link.scss', `@use "base";\n\n${readFileSync('project/_link.scss', 'utf8')}`);
const fixed = compile('project/main.scss');
console.log(`=== With @use "base"; added to _link.scss:\n${fixed}\n\nSame CSS as with @import: ${fixed === before ? 'yes' : 'no'}`); Output
=== With @import:
.btn, .link-button {
padding: 4px 12px;
}
.link-button {
color: #1d4ed8;
}
=== After sass-migrator module, main.scss:
@use "base";
@use "link";
=== Compiled:
Error: The target selector was not found.
Use "@extend .btn !optional" to avoid this error.
=== With @use "base"; added to _link.scss:
.btn, .link-button {
padding: 4px 12px;
}
.link-button {
color: #1d4ed8;
}
Same CSS as with @import: yes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node extend.mjs
The fix is one line: the extending partial loads the module whose selector it extends. Search a codebase for
@extend before migrating, and give every extending file a @use of the files that define its targets. Avoid
!optional as a fix: it makes the error go away by dropping the extension, so the CSS changes silently.
Frameworks that still use @import
Bootstrap 5.3.8 is written with @import and global functions, and its documentation says that the deprecation
warnings “can be ignored” for now. A site that @imports it cannot be migrated automatically: the migrator wants
to rename members inside Bootstrap’s files, and it never edits files found through a load path.
bootstrap/site.scss
// Before: Bootstrap configured by variables set ahead of an @import.
$primary: #0f766e;
$enable-shadows: true;
@import "bootstrap/scss/bootstrap";
.hero {
padding: $spacer * 3;
background: tint-color($primary, 80%);
} bootstrap/site-use.scss
// After: the same configuration in a with clause, and Bootstrap's members under the namespace bs.
@use "pkg:bootstrap" as bs with (
$primary: #0f766e,
$enable-shadows: true
);
.hero {
padding: bs.$spacer * 3;
background: bs.tint-color(bs.$primary, 80%);
} // Tries sass-migrator on a site that @imports Bootstrap 5.3.8, then compares the hand-migrated version with it.
// Run it in this folder after `npm install` in the examples folder: node bootstrap.mjs
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import * as sass from 'sass';
import { installBootstrap } from '../vendor.mjs';
installBootstrap('.');
const MIGRATOR = createRequire(import.meta.url).resolve('sass-migrator/sass-migrator.js');
copyFileSync('site.scss', 'site-migrated.scss');
const run = spawnSync(process.execPath, [MIGRATOR, 'module', '--migrate-deps', '--load-path=node_modules', 'site-migrated.scss'], { encoding: 'utf8' });
console.log(`$ sass-migrator module --migrate-deps --load-path=node_modules site-migrated.scss (exit ${run.status})\n${`${run.stdout}${run.stderr}`.trim()}\n`);
function compile(file, options) {
let warnings = 0;
const { css } = sass.compile(file, { verbose: true, logger: { warn: () => warnings++ }, ...options });
const sha = createHash('sha256').update(css).digest('hex').slice(0, 12);
console.log(`${file.padEnd(14)} ${String(warnings).padStart(3)} warnings, ${css.length.toLocaleString('en')} bytes, sha256 ${sha}`);
return css;
}
const before = compile('site.scss', { loadPaths: ['node_modules'] });
const after = compile('site-use.scss', { importers: [new sass.NodePackageImporter()], quietDeps: true });
console.log(`The two compiled stylesheets are ${before === after ? 'byte-identical' : 'different'}.`); Output
$ sass-migrator module --migrate-deps --load-path=node_modules site-migrated.scss (exit 1) Error: The migrator wants to rename a member in node_modules/bootstrap/scss/_functions.scss, but it is not being migrated. You should re-run the migrator with --migrate-deps or with node_modules/bootstrap/scss/_functions.scss as one of your entrypoints. Migration failed! site.scss 333 warnings, 278,478 bytes, sha256 785a37c9a021 site-use.scss 0 warnings, 278,478 bytes, sha256 785a37c9a021 The two compiled stylesheets are byte-identical.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bootstrap.mjs
The message suggests --migrate-deps, which the command already had; what it means is that the member’s file is
outside the files it may change. Migrate your own files and load the framework through one @use:
@use "pkg:bootstrap" as bs with (…) takes the same settings the variables used to set, and Bootstrap’s members
are then bs.$spacer and bs.tint-color(). The two versions compiled to the same 278,478 bytes with the same hash,
and with quietDeps the warning count fell from 333 to 0. The framework still uses @import inside its own files,
but that is its maintainers’ migration, and loading it as one module keeps it out of yours.
Globals shared across files
Old codebases often rely on order: a component reads $gap without loading anything, because some earlier
@import defined it. The module migration makes each of those dependencies explicit by adding a @use to the file
that needs it, as it did for the button above. Patterns it cannot express need a decision by hand:
- A variable reassigned between imports, so later partials see a new value: give the setting
!defaultand configure it once, or pass the value to a mixin as an argument. - Several theme entry points that set globals before importing the same partials: each becomes an entry point
that configures the theme module with
with, as the lesson on@useand@forwardshowed. - A partial that both emits CSS and defines members, imported in several places: split it into a module of members and a mixin that emits the CSS, so each use is explicit.
Keeping it migrated in CI
Once your files are clean, make backsliding fail the build. Compile in CI with the migrated deprecations fatal and the dependencies quiet:
ci/app.scss
// Migrated, except for one @import that slipped back in.
@use "pkg:bootstrap" as bs with ($primary: #0f766e);
@import "banner";
.hero {
padding: bs.$spacer * 3;
} ci/app-fixed.scss
// The same file with the stray @import replaced.
@use "pkg:bootstrap" as bs with ($primary: #0f766e);
@use "banner";
.hero {
padding: bs.$spacer * 3;
} ci/_banner.scss
.banner {
padding: 1rem;
background: #fef3c7;
} // The CI step: compile with every migrated deprecation fatal for your own files, and Bootstrap's warnings quiet.
// Run it in this folder after `npm install` in the examples folder: node ci.mjs
import { spawnSync } from 'node:child_process';
import { statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { installBootstrap } from '../vendor.mjs';
installBootstrap('.');
const SASS = join(dirname(createRequire(import.meta.url).resolve('sass')), 'sass.js');
const FLAGS = ['--pkg-importer=node', '--quiet-deps', '--fatal-deprecation=import,global-builtin,slash-div,color-functions,if-function', '--no-error-css', '--no-source-map'];
for (const file of ['app.scss', 'app-fixed.scss']) {
const out = file.replace('.scss', '.css');
const run = spawnSync(process.execPath, [SASS, ...FLAGS, file, out], { encoding: 'utf8' });
console.log(`$ sass ${FLAGS.join(' ')} ${file} ${out}`);
if (run.stderr.trim()) console.log(run.stderr.trim());
let size = 'not written';
try {
size = `${statSync(out).size.toLocaleString('en')} bytes`;
} catch {}
console.log(`exit status ${run.status}; ${out}: ${size}\n`);
} Output
$ sass --pkg-importer=node --quiet-deps --fatal-deprecation=import,global-builtin,slash-div,color-functions,if-function --no-error-css --no-source-map app.scss app.css Error: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0. More info and automated migrator: https://sass-lang.com/d/import This is only an error because you've set the import deprecation to be fatal. Remove this setting if you need to keep using this feature. ╷ 3 │ @import "banner"; │ ^^^^^^^^ ╵ app.scss 3:9 root stylesheet exit status 65; app.css: not written $ sass --pkg-importer=node --quiet-deps --fatal-deprecation=import,global-builtin,slash-div,color-functions,if-function --no-error-css --no-source-map app-fixed.scss app-fixed.css exit status 0; app-fixed.css: 276,989 bytes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ci.mjs
The stray @import failed the step with exit status 65, and --no-error-css kept Sass from writing a CSS file
that describes the error, which it does by default when compiling to a file. The fixed file passed and produced
276,989 bytes, Bootstrap included. With --quiet-deps, fatal deprecations apply only to your own files, so
Bootstrap’s internal @import rules do not fail the build. --fatal-deprecation also accepts a version:
--fatal-deprecation=1.80.0 makes every deprecation up to that release fatal.
Exercise: migrate a small legacy codebase
The exercise folder holds a legacy codebase in src/ and a third-party grid in vendor/, compiled with the load
path vendor. It uses globals, a nested import, darken() and lighten(), and / as division:
src/main.scss · exercise/src/main.scss
// The entry point. Compile it with the load path "vendor".
$brand: #0f766e;
$radius: 4px;
@import "settings";
@import "functions";
@import "mixins";
@import "grid";
@import "components/button";
@import "components/card"; src/_settings.scss · exercise/src/_settings.scss
$brand: #7c3aed !default;
$gap: 24px !default;
$radius: 8px !default;
$breakpoints: (md: 768px, lg: 1024px); src/_functions.scss · exercise/src/_functions.scss
@function rem($px) {
@return $px / 16px * 1rem;
} src/_mixins.scss · exercise/src/_mixins.scss
@mixin media-up($name) {
@media (min-width: map-get($breakpoints, $name)) {
@content;
}
} components/_button.scss · exercise/src/components/_button.scss
.button {
padding: rem(10px) rem($gap);
border-radius: $radius;
background: $brand;
color: white;
&:hover {
background: darken($brand, 10%);
}
} components/_card.scss · exercise/src/components/_card.scss
.card {
padding: $gap / 2;
border: 1px solid lighten($brand, 35%);
@import "card-parts";
@include media-up(md) {
@include make-columns(6);
}
} components/_card-parts.scss · exercise/src/components/_card-parts.scss
&__title {
font-size: rem(20px);
}
&__media {
border-radius: $radius $radius 0 0;
} vendor/grid/_index.scss · exercise/vendor/grid/_index.scss
// A third-party grid, loaded through the load path "vendor". Do not change this folder.
$grid-columns: 12 !default;
@mixin make-columns($span) {
flex: 0 0 percentage($span / $grid-columns);
} Migrate src/ to the module system without changing vendor/, so that it compiles with no deprecation warnings
from your files and produces exactly the CSS it produced before. The sample tests compile the codebase the way CI
will. Run node check.mjs in the exercise folder: on the files as given, it passes one test and fails four:
// Sample tests for the exercise: the CI settings, the CSS against the original, and what is left to migrate.
// Run it in this folder after `npm install` in the examples folder: node check.mjs
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as sass from 'sass';
// What src/main.scss compiled to before the migration (Dart Sass 1.105.1).
const ORIGINAL = `.button {
padding: 0.625rem 1.5rem;
border-radius: 4px;
background: #0f766e;
color: white;
}
.button:hover {
background: rgb(3.6267138434%, 28.5301489017%, 26.5959015185%);
}
.card {
padding: 12px;
border: 1px solid rgb(30.9361639393%, 91.2206988058%, 86.5384048356%);
}
.card__title {
font-size: 1.25rem;
}
.card__media {
border-radius: 4px 4px 0 0;
}
@media (min-width: 768px) {
.card {
flex: 0 0 50%;
}
}`;
const VENDOR_SHA256 = 'f6874f7bf0b6cfe1865728c21e96d9962ef999d1def6658cdee50546d11f2e60';
const FATAL = ['import', 'global-builtin', 'slash-div', 'color-functions'];
const files = (dir) => readdirSync(dir, { recursive: true }).filter((f) => f.endsWith('.scss')).map((f) => join(dir, f));
let ci = null;
let ciError = null;
try {
ci = sass.compile('src/main.scss', { loadPaths: ['vendor'], quietDeps: true, fatalDeprecations: FATAL, logger: sass.Logger.silent }).css;
} catch (error) {
ciError = error.message.split('\n')[0];
}
let own = 0;
try {
sass.compile('src/main.scss', {
loadPaths: ['vendor'],
verbose: true,
logger: { warn: (_m, { span }) => { if (span?.url && !fileURLToPath(span.url).includes(`${join('/', 'vendor', '/')}`)) own++; } },
});
} catch {
own = NaN;
}
const imports = files('src').filter((f) => /^\s*@import\b/m.test(readFileSync(f, 'utf8')));
const vendor = createHash('sha256').update(readFileSync('vendor/grid/_index.scss')).digest('hex');
const tests = [
['src/main.scss compiles with the CI settings', ci !== null, ciError],
['the CSS is byte-identical to the original', ci === ORIGINAL, ci === null ? 'nothing to compare' : 'the CSS differs'],
['no deprecation warnings from src/', own === 0, Number.isNaN(own) ? 'it does not compile' : `${own} warnings`],
['no @import rules left in src/', imports.length === 0, `still in ${imports.join(', ')}`],
['vendor/ is unchanged', vendor === VENDOR_SHA256, 'vendor/grid/_index.scss was edited'],
];
let failed = 0;
for (const [name, ok, why] of tests) {
if (!ok) failed++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` (${why})`}`);
}
console.log(`\n${tests.length - failed} of ${tests.length} tests pass`);
process.exitCode = failed ? 1 : 0; Output (exit status 1)
FAIL src/main.scss compiles with the CI settings (Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.) FAIL the CSS is byte-identical to the original (nothing to compare) FAIL no deprecation warnings from src/ (14 warnings) FAIL no @import rules left in src/ (still in src/main.scss, src/components/_card.scss) PASS vendor/ is unchanged 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: run the migrator with --migrate-deps and the same load path the compiler uses, then compile. One
file needs the recipe from the section on nested imports.
Self-check
Check yourself
6 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
Why is Sass deprecating @import, and when will it stop working?
@import puts every member of every imported file into one global scope. Libraries had to give their members long
prefixed names to avoid collisions, nobody could tell where a variable came from without searching, and a file
imported twice was evaluated twice and emitted its CSS twice, which also slowed compilation. The module system
fixes all three, but Sass can only rely on it fully once @import is gone. Dart Sass 1.80.0 deprecated @import
and the global built-in functions together. Sass plans to remove both in Dart Sass 3.0.0, which it says will come
no sooner than two years after 1.80.0, and after that @import is treated as a plain CSS import. Dart Sass 2.0.0
comes first and removes / as division and the old colour functions.
Key takeaways
@importand global built-in functions are deprecated since Dart Sass 1.80.0 and go in 3.0.0;/as division and the old colour functions go earlier, in 2.0.0.- A warning’s ID is in square brackets, and its stack shows whether it is yours or a dependency’s.
- Terse mode prints five warnings per type;
--verboseprints all,--quiet-depshides dependencies’ warnings. - Run
sass-migrator module --migrate-deps, thendivision,colorandif-function, and compile after each. - Prove each step with a byte comparison against a baseline compiled with a pinned Sass version.
- Wrap nested-import partials that use
&in a mixin;meta.load-css()cannot resolve&__title. - An
@extendreaches only the modules its file loads: add the@usethe migrator leaves out. - Load a framework that still uses
@importthrough one configured@useand quiet its warnings. - Guard the result in CI with
--quiet-deps,--fatal-deprecationand--no-error-css.
References
- Breaking change: @import and global built-in functions (Sass team (sass-lang.com))
- Breaking changes (overview) (Sass team (sass-lang.com))
- Migrator (Sass team (sass-lang.com))
- Dart Sass command-line interface: warnings and deprecations (Sass team (sass-lang.com))
- JavaScript API: Options (quietDeps, fatalDeprecations, silenceDeprecations, verbose) (Sass team (sass-lang.com))
- @extend: extension scope (Sass team (sass-lang.com))
- Breaking change: slash as division (Sass team (sass-lang.com))
- Breaking change: color functions (Sass team (sass-lang.com))
- Breaking change: legacy if() function (Sass team (sass-lang.com))
- @import is deprecated (Sass team (sass-lang.com))
- sass/migrator: source and changelog (Sass team (GitHub))
- Bootstrap 5.3: Sass (Bootstrap team)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress