🍱 Lunchbox Hands

css

Sass @import Is Deprecated: Migrating to @use and @forward

What the "Sass @import rules are deprecated" warning means, when Dart Sass 3.0 removes @import, how @use namespaces, as *, with() and @forward replace it, why darken() and map-get() warn too, and how sass-migrator does the rewrite for you.

Upgrade Dart Sass past 1.80 and a build that was silent for years starts printing a yellow wall on every compile, one block per @import, followed by more blocks about darken() and map-get(). Nothing is broken yet, and the CSS comes out the same. The warning is not about a syntax change. It’s about scope: @import dumps every variable, mixin and function into one shared global namespace, and the module system that replaces it gives each file its own namespace, loads it once, and makes you say where a name came from. Once that clicks, the migration is mostly mechanical, and a tool can do most of it for you.

Everything below was run against Dart Sass 1.104.0 (the sass npm package), sass-migrator 2.6.1 and Less 4.9.1 on Node 22. The output is pasted exactly as it came out.

The exact warning, and what it’s promising

Take a partial _vars.scss that defines $primary and a stylesheet that pulls it in the old way:

// old.scss
@import "vars";
.btn { color: $primary; background: darken($primary, 10%); padding: map-get((a: 4px), a); }

Compiling it prints four deprecation blocks. The first is the one people search for:

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

  ╷
1 │ @import "vars";
  │         ^^^^^^
  ╵
    old.scss 1:9  root stylesheet

The word in brackets, [import], is the deprecation ID. It’s the handle you use to silence the warning or make it fatal, so remember it. The other three blocks are [global-builtin] (twice, once each for darken and map-get) and [color-functions] (for darken). They’re separate deprecations with separate IDs, and they come up again below.

The timeline comes from the Sass team. The announcement post (17 October 2024) says “@import is officially deprecated as of Dart Sass 1.80.0”, and 1.80.0 hit npm the same day. The deprecation page sets the removal date: “we don’t expect to remove Sass @import rules or global built-in functions until Dart Sass 3.0.0, which will be released no sooner than two years after Dart Sass 1.80.0.” Dart Sass 2.0.0 is a separate, earlier breaking release. @import survives it and goes away in 3.0.0.

So nothing breaks today, but this is a firm deadline with a date on it, not a vague “might change someday.”

What @import actually did wrong

@import pastes a file’s contents in. That design caused three real problems, and each module-system rule below is a fix for one of them.

Everything is global. A variable defined in any imported file is visible to every file imported after it. When a component partial uses $primary, nothing tells you which of 40 partials defined it, and two libraries that both define $gap silently overwrite each other.

Files load once per @import. Import a partial twice and its CSS appears twice. Real output from importing a _base.scss containing .base { margin: 0; }:

@import "base";
@import "base";
.base {
  margin: 0;
}

.base {
  margin: 0;
}

Swap in @use "base"; @use "base" as b2; and .base is emitted exactly once. The @use docs promise this: styles “will be included exactly once in the compiled CSS output, no matter how many times those styles are loaded.”

There’s no privacy. A library’s internal helper variables are as reachable as its public API, so renaming one breaks downstream code.

@use: namespaces, as *, and with()

@use loads a module and puts its members behind a namespace. By default the namespace is the last path segment of the URL:

@use "vars";
@use "sass:color";

.btn {
  color: vars.$primary;
  background: color.adjust(vars.$primary, $lightness: -10%);
}

Note that the $ goes after the dot (vars.$primary), not before the namespace. You can pick your own name with @use "vars" as v;, which gives you v.$primary. You can also drop the namespace with as *:

@use "vars" as *;
a { b: $primary; c: double(3px); }
a {
  b: #2563eb;
  c: 6px;
}

as * is the quickest way to get a big codebase compiling, but it brings back the “where did this come from?” problem for that one file. Save it for a single design-tokens module, not every dependency.

Configuration replaces “define before import”. With @import, you set $primary: red; above the import and the library’s $primary: blue !default; backed off. With @use you pass the values explicitly:

@use "vars" with ($primary: #e11d48);

That compiled color: #e11d48. Only variables declared with !default can be configured. Try any other name and compilation stops:

ERROR: This variable was not declared with !default in the @used module.

Ordering is enforced. Per the @use docs, “A stylesheet’s @use rules must come before any rules other than @forward.” Put a{b:1} before a @use and you get @use rules must be written before any other rules. Loading the same URL twice under the same namespace is also an error: There's already a module with namespace "vars".

Private members are real. Start a name with - or _ and it stays out of the module’s public API. We added $-secret: 1px; to _vars.scss and then tried to reach it:

ERROR: Private members can't be accessed from outside their modules.
  ╷
1 │ @use "vars"; a { b: vars.$-secret; }
  │                     ^^^^^^^^^^^^^

@forward: building an entry point

@use makes members visible to this file. @forward re-exports them to whoever uses this file, which is how a library ships one _index.scss that fronts dozens of partials. It can filter and prefix:

// _theme.scss
@forward "vars" show $primary, double;
@forward "vars" as vars-* hide $radius;
@use "theme";
a { b: theme.$primary; c: theme.vars-double(2px); }
a {
  b: #2563eb;
  c: 4px;
}

A file can @forward a module and also @use it, and a folder URL resolves to its _index.scss. The usual layout is a _index.scss per folder that does nothing but forward.

The other warnings: global built-ins and darken()

The same release deprecated the global versions of functions that now live in sass: modules. That’s what [global-builtin] is about: map-get becomes map.get, percentage becomes math.percentage, mix becomes color.mix, and so on. Each one needs the matching @use "sass:map" (or math, color, string, list, selector, meta) at the top of the file. These built-in modules never touch the filesystem, and they’re on the same Dart Sass 3.0.0 removal schedule as @import.

darken() is a special case: it triggers two deprecations. Besides [global-builtin], it gets [color-functions], which covers lighten, darken, saturate, desaturate, adjust-hue, opacify/fade-in and transparentize/fade-out. There is no color.darken. According to the color-functions page, those warnings began in 1.79.0 and “In Dart Sass 2.0.0, these functions will be removed entirely”, so they go away one major version earlier than @import. The compiler suggests two replacements:

DEPRECATION WARNING [color-functions]: darken() is deprecated. Suggestions:

color.scale($color, $lightness: -18.75%)
color.adjust($color, $lightness: -10%)

The two replacements are not interchangeable. color.adjust makes an absolute change, exactly like the old darken. color.scale makes a relative change: it moves the value that fraction of the way toward the limit. Run both on a light pink, hsl(0, 50%, 80%), with -10%:

a {
  adjust: hsl(0, 50%, 70%);
  scale: hsl(0, 50%, 72%);
  darken: hsl(0, 50%, 70%);
}

adjust subtracted 10 points (80 → 70), the same as darken. scale took 10% of the current lightness (80 × 0.9 = 72). For a pixel-identical migration, use color.adjust. The -18.75% in the color.scale suggestion is the compiler working out the relative amount that matches for the specific $primary in the file (lightness 53.33%, and 10 / 53.33 = 18.75%).

One more thing you’ll see in 1.104.0 output: darken(#2563eb, 10%) compiles to rgb(7.2829131653%, 29.8599439776%, 79.3837535014%), not a hex code, because the result doesn’t land on whole 0–255 channels and modern Dart Sass keeps the precision instead of rounding. Less rounds the same operation to #134cca.

Before and after, at a glance

@import worldModule system
@import "vars";@use "vars";
$primaryvars.$primary (or as * for no prefix)
$primary: red; then @import "lib";@use "lib" with ($primary: red);
partial re-exports via @import chains@forward "vars"; in an _index.scss
no privacy$-name / $_name are private
same file imported twice → CSS twiceloaded once, CSS emitted once
map-get($m, k)@use "sass:map"; → map.get($m, k)
darken($c, 10%)@use "sass:color"; → color.adjust($c, $lightness: -10%)
@import inside a rule blocknot allowed — use meta.load-css()

Let sass-migrator do it

You don’t have to rewrite hundreds of files by hand. The Sass team publishes the migrator, and the deprecation page gives the command: sass-migrator module --migrate-deps your-entrypoint.scss. --migrate-deps follows every dependency the entry point loads. Dry-run it first:

$ npx sass-migrator module --migrate-deps --dry-run --verbose old.scss
Dry run. Logging migrated files instead of overwriting...

<===> old.scss
@use "sass:color";
@use "sass:map";
@use "vars";
.btn { color: vars.$primary; background: color.adjust(vars.$primary, $lightness: -10%); padding: map.get((a: 4px), a); }

In one pass it added the namespaces, the built-in @use lines, the absolute-adjust replacement for darken, and map.get. Review the diff like any other code change, especially where a library expected to be configured by variables set above its import, since those turn into with (...) clauses.

Silencing it, on purpose

If you can’t migrate this sprint, mute the warnings by ID rather than ignoring the whole log. On the CLI:

$ sass --silence-deprecation=import old.scss

The [import] block disappears, but [global-builtin] still prints. The docs note that you “need to pass both import and global-builtin”. In the JS API (and in Vite’s or webpack’s sassOptions), the option is an array:

sass.compile('old.scss', { silenceDeprecations: ['import', 'global-builtin'] });

Once you’ve migrated, flip it around. fatalDeprecations: ['import'] (or --fatal-deprecation=import) turns any new @import into a build error. If you set both, fatal wins, and Sass tells you so: WARNING: Ignoring setting to silence import deprecation, since it has also been made fatal.

node-sass and LibSass are not an escape hatch

It’s tempting to “fix” the warning by pinning an old compiler. The team deprecated LibSass on 26 October 2020: “LibSass and the packages built on top of it, including Node Sass, are deprecated”, with no new features, “including compatibility with new CSS features.” npm view node-sass deprecated now returns Node Sass is no longer supported. Please use sass or sass-embedded instead. Staying on it swaps a warning you can plan around for a compiler that never gets fixed.

What about Less?

Less has its own @import, and it isn’t deprecated. It’s a different language that happens to share the keyword. Less @import defaults to the once option (docs: “only include the file once”), which is exactly the double-output problem Sass had. Importing the same vars.less twice with Less 4.9.1 emitted .base once, and darken(@brand, 10%) is still a first-class Less function. If you write Less, no migration is needed.

Trying it in our compilers

The SCSS → CSS compiler runs this same Dart Sass 1.104.0 in your browser tab, and there’s an honest limitation: a browser tab has no filesystem. Any @use, @forward or @import of something other than a sass: module fails with module 'vars' not available in the browser playground; paste its contents, and the tool lists the missing modules above the editor. Because the load fails before Sass would warn, you won’t see the [import] warning there. Paste the partial’s contents in place of the line instead. Plain-CSS imports ("x.css", url(...), a trailing media query) pass through untouched, as they do in Sass.

What the tool does show is everything else in the yellow panel: [global-builtin], [color-functions], [slash-div] and friends. While writing this post we changed it to print the ID in brackets, e.g. Deprecation [global-builtin] (line 1): …, so you can see exactly what to pass to silenceDeprecations. It’s a quick way to check a single map-get/darken-heavy file before running the migrator on the repo.

The LESS → CSS compiler has the same no-filesystem rule for .less imports, and it flags each one by line number. The CSS formatter will tidy whatever comes out. If you’re sizing type in either preprocessor, the clamp() math post explains why the compiled declaration is the only version the browser ever sees.