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

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.

  • Advanced
  • 35 minutes
  • Examples run with Node.js 24.21.0, Dart Sass 1.105.1 and sass-migrator 2.6.1
  • By MySmartCoPilot
Free: 35 minutes

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:

A site that still imports Bootstrap

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"
  }
}
Where the warnings come from JavaScript · warnings/report.mjs
// 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 (or verbose: true in 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 in site.scss remain, 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:

The project before the migration

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);
  }
}
Four migrations, and the CSS after each JavaScript · migrate.mjs
// 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

Five migration steps: baseline, module migrator, other migrators, hand fixes, CI guard; the CSS is compared after each step.1. Baselinecompile, keep the CSS,count warnings by type2. sass-migrator module --migrate-deps@import becomes @use, members getnamespaces, globals become with ()3. sass-migrator division,color and if-function4. By handnested imports with & suffixes,frameworks that use @import5. Guard in CI--quiet-deps and--fatal-deprecationAfter each step:is the CSS byte-identicalto the baseline?checkcheckcheck

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.

  1. Baseline: compile the project as it is, keep the CSS, and count the deprecation warnings by type.
  2. 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.
  3. Run the division, color and if-function migrations.
  4. Fix by hand what the migrator cannot: nested imports whose rules use & with a suffix, and frameworks that still use @import.
  5. 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:

  1. 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.
  2. Compile every entry point before you start and keep the files as the baseline.
  3. After each migration, compile again and compare: a hash, cmp, or a diff of the two files.
  4. 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.

Text Diff Paste the CSS from before and after a migration step to see every changed line.

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:

Two nested imports, and a mixin version of the second

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;
}
Nested imports through the migrator JavaScript · nested/nested.mjs
// 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:

An @extend that relies on import order

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;
}
The extension before and after the migration JavaScript · extend/extend.mjs
// 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.

The site before and after, migrated by hand

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%);
}
The migrator's answer, and the two versions compared JavaScript · bootstrap/bootstrap.mjs
// 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 !default and 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 @use and @forward showed.
  • 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:

A file with a stray @import, and the fixed file

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, run on both files JavaScript · ci/ci.mjs
// 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:

The legacy codebase

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:

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

  1. Question 1 of 6 A codebase uses @import, map-get() and / for division. Which of them stops working first?

    Choose one answer.

    Show the answer to question 1

    Answer: / for division, removed in Dart Sass 2.0.0

    / as division and the old colour functions are removed in Dart Sass 2.0.0. @import and the global built-in functions such as map-get() stay until Dart Sass 3.0.0.

  2. Question 2 of 6 A compile hits 40 import warnings and 3 slash-div warnings, all in your own files. In the default terse mode, how many deprecation warnings does Dart Sass print in full before its summary line?

    Type a number.

    Show the answer to question 2

    Answer: 8

    Terse mode prints each type five times: 5 of the 40 import warnings and all 3 slash-div warnings, so 8, followed by "35 repetitive deprecation warnings omitted".

  3. Question 3 of 6 With --quiet-deps, which warnings are hidden?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • Deprecation warnings from files loaded through an importer, such as pkg: URLs in the JavaScript API
    • Deprecation warnings from files found through a load path

    A dependency is a file reached through a load path or, in most cases, an importer. Files loaded by relative URLs from the entry point are yours. --quiet-deps also leaves @warn and @debug alone.

  4. Question 4 of 6 The module migration turns this nested import into meta.load-css("parts"). What happens when the result is compiled?

    Read the code, then choose one answer.

    // _parts.scss
    &__title {
      font-weight: 600;
    }
    
    // card.scss
    .card {
      @import "parts";
    }
    Show the answer to question 4

    Answer: An error: a top-level selector may not contain a parent selector with a suffix

    meta.load-css() compiles the module on its own before nesting its CSS, so &__title has no parent to attach to. Wrapping the rules in a mixin and including it inside .card keeps .card__title.

  5. Question 5 of 6 Put these steps of a careful migration in order.

    Give each item its position, from 1 (first).

    Show the answer to question 5

    Answer:

    1. Compile every entry point and keep the CSS as the baseline
    2. Run sass-migrator module --migrate-deps
    3. Run the division, color and if-function migrations
    4. Fix by hand what is left, comparing the CSS with the baseline
    5. Make the migrated deprecations fatal in CI

    The baseline must come before any change, the module migration rewrites the most and goes first, and the CI guard comes last, once your files compile without warnings.

  6. Question 6 of 6 sass-migrator module --migrate-deps --load-path=node_modules site.scss stops with "The migrator wants to rename a member in node_modules/bootstrap/scss/_functions.scss, but it is not being migrated." What is the right next step?

    Choose one answer.

    Show the answer to question 6

    Answer: Migrate your own files and load Bootstrap through one configured @use, with its warnings quieted

    The migrator never changes files found through a load path, and editing a package in node_modules would be undone by the next install. A configured @use "pkg:bootstrap" as bs with (…) produced byte-identical CSS in the example, with no warnings once quietDeps was on.

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

  • @import and 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; --verbose prints all, --quiet-deps hides dependencies’ warnings.
  • Run sass-migrator module --migrate-deps, then division, color and if-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 @extend reaches only the modules its file loads: add the @use the migrator leaves out.
  • Load a framework that still uses @import through one configured @use and quiet its warnings.
  • Guard the result in CI with --quiet-deps, --fatal-deprecation and --no-error-css.

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.