Mert Özen Further With Every Line
Frontend Sass/SCSS — #04

The Sass Module System: @use, @forward, Namespaces, Private Members and Migrating from @import

Mert Özen Nov 14, 2026 11 min 11 views
The Sass Module System: @use, @forward, Namespaces, Private Members and Migrating from @import

@use loads each file once and namespaced, and private members draw the public API; @forward gives you an index file, !default and with() configure libraries. We show how @import's global namespace overwrote the brand color and emitted CSS twice, migrate the tree with sass-migrator, and set up CSS-free abstracts in Angular with includePaths; abstracts that emit CSS get copied into every component.

Version note: The examples in this post were run with Dart Sass 1.108.2, Sass Migrator 2.7.0 and Angular 22.2 (TypeScript 6.0; the Sass 1.104.1 bundled with the Angular CLI).

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, has a style tree written years ago. The brand color is brown; one of the files in between is the partial of an old datepicker copied into the repo:

// _variables.scss
$brand: #b45309;
$space: 16px;
$radius: 8px;

// vendor/_datepicker.scss
$brand: #2563eb;
$radius: 2px;

.datepicker {
    border-radius: $radius;
    border: 1px solid $brand;
}

// _buttons.scss
.button {
    padding: $space * 0.5 $space;
    background-color: $brand;
    color: #ffffff;
}

// _book-card.scss and _cart.scss both import buttons
@import "buttons";

// main.scss
@import "variables";
@import "mixins";
@import "vendor/datepicker";
@import "book-card";
@import "cart";

What color is .button in the compiled CSS? And how many times does it appear? The answers to both questions explain on their own why @import is deprecated.

In the previous posts we worked with single-file examples. In a real project style code is split into dozens of files, and how those files see each other is the part of Sass that has changed the most. In this post we look at the module system that arrived in 2019 (@use and @forward), migrate the old tree with sass-migrator, and use shared Sass code in the components of Bookstore's Angular client (bookstore-web) without duplicating CSS.

The Problems with @import

npx sass 04/legacy/main.scss
DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
...
DEPRECATION WARNING [global-builtin]: Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
DEPRECATION WARNING [color-functions]: darken() is deprecated. Suggestions:
...
DEPRECATION WARNING [slash-div]: Using / for division is deprecated and will be removed in Dart Sass 2.0.0.
...
WARNING: 2 repetitive deprecation warnings omitted.

.datepicker {
  border-radius: 2px;
  border: 1px solid #2563eb;
}

.button {
  padding: 8px 16px;
  background-color: #2563eb;
  color: #ffffff;
}

.book-card {
  padding: 16px;
  border: 1px solid rgb(7.2829131653%, 29.8599439776%, 79.3837535014%);
  border-radius: 2px;
}
.book-card__price {
  color: #b91c1c;
}

.button {
  padding: 8px 16px;
  background-color: #2563eb;
  color: #ffffff;
}

.cart {
  padding: 16px;
  border: 1px solid rgb(7.2829131653%, 29.8599439776%, 79.3837535014%);
  border-radius: 2px;
  width: 33.3333333333%;
}

The button is blue and written twice. Three problems at once:

  • There is a single global namespace. @import pastes the file's content where it stands; the datepicker's $brand silently overwrites Bookstore's, and every rule compiled after it (the button, the card border, the corner radius) becomes blue and 2px.
  • The same file is compiled again on every import. Because both the card and the cart import _buttons.scss, .button appears twice in the output. As the number of files grows, this inflates both the CSS and the compile time.
  • Order dependency. _mixins.scss uses $brand but does not import it; it only works because main.scss imports variables first. You cannot tell what a file needs by looking at the file itself.

Two of the warnings deserve extra attention: @import and the global built-in functions (darken(), map-get(), percentage()) will be removed in Dart Sass 3.0.0, and slash-div in 2.0.0. The percentage rgb() value on the card border is not a bug: since 1.79.0 Sass no longer rounds the result of color functions to integers, and since 1.101.4 it writes non-integer channels as percentages; it is valid CSS.

@use: Namespaces, as * and Private Members

I rewrote the same tree with the module system. Each file loads what it needs itself with @use and accesses its members through a namespace. Shared design values and mixins live in the abstracts folder:

// abstracts/_tokens.scss
$brand: #b45309;
$space: 16px;
$radius: 8px;

// abstracts/_mixins.scss
@use "sass:color";
@use "tokens";

// private: starts with -, invisible outside this file
$-border-shade: -10%;

@function -border-color($color) {
    @return color.scale($color, $lightness: $-border-shade);
}

@mixin card-surface {
    padding: tokens.$space;
    border: 1px solid -border-color(tokens.$brand);
    border-radius: tokens.$radius;
}

// only for debugging layouts, not part of the public API
@mixin debug-outline {
    outline: 1px dashed magenta;
}

@use "tokens" loads the file and takes the namespace from the file name: tokens.$space. You can change the namespace with @use "tokens" as t, or drop it entirely with as *. Built-in modules load the same way: sass:color, sass:map, sass:math. Variables, mixins and functions are namespaced; CSS rules are not, because they go straight to the output.

Members whose names start with - or _ are private. They can be used freely inside the file, but accessing them from outside is a compile error:

@use "../modern/abstracts/mixins";

.debug {
    border-color: mixins.$-border-shade;
}
Error: Private members can't be accessed from outside their modules.
  ╷
4 │     border-color: mixins.$-border-shade;
  │                   ^^^^^^^^^^^^^^^^^^^^^
  ╵
  errors/private.scss 4:19  root stylesheet

This lets you draw a clear line around the public API of a style library shared between teams. When you change -border-color() tomorrow, you know no other file uses it.

A module's CSS is produced once per compilation, no matter how many files @use it. One more rule: @use lines must come at the very top of the file, before any other rules (only @forward and variable declarations may precede them).

An Index File with @forward: show and hide

Instead of every component loading tokens and mixins separately, I added an index file to the abstracts folder that exposes them together:

// abstracts/_index.scss
@forward "tokens";
@forward "mixins" hide debug-outline;

@forward exposes the public members of the module it loads as if they were its own, but does not make them usable inside the file (for that you need a separate @use). The name _index.scss is special: when @use "abstracts" loads the folder, Sass looks for this file. hide hides the listed members, while show exposes only the listed ones. debug-outline is no longer visible through the index:

Error: Undefined mixin.
  ╷
4 │     @include abstracts.debug-outline;
  │     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  ╵
  errors/hidden.scss 4:5  root stylesheet

Component files now start with a single line. _buttons.scss loads it with a namespace, _book-card.scss without one, via as *:

// components/_buttons.scss
@use "../abstracts";

.button {
    padding: abstracts.$space * 0.5 abstracts.$space;
    background-color: abstracts.$brand;
    color: #ffffff;
}

// components/_book-card.scss
@use "sass:map";
@use "../abstracts" as *;
@use "buttons";

$price-colors: (sale: #b91c1c, regular: #1c1917);

.book-card {
    @include card-surface;

    &__price {
        color: map.get($price-colors, sale);
    }
}

as * shortens the code but loses the "where does this value come from?" information a namespace gives you, and if two modules expose a member with the same name, Sass fails with This mixin is available from multiple global modules. on the line that uses it. My rule: as * is fine for the project's own abstracts, third-party libraries always get a namespace.

Configuring a Library with with()

The right way to fix the datepicker is to turn it into a module and make it configurable from outside. !default means "use this unless the variable has already been given a value":

// vendor/_datepicker.scss
// configurable from outside with @use ... with ()
$brand: #2563eb !default;
$radius: 2px !default;

.datepicker {
    border-radius: $radius;
    border: 1px solid $brand;
}

// main.scss
@use "abstracts";
@use "vendor/datepicker" with (
    $brand: abstracts.$brand,
    $radius: abstracts.$radius
);
@use "components/book-card";
@use "components/cart";
npx sass 04/modern/main.scss
.datepicker {
  border-radius: 8px;
  border: 1px solid #b45309;
}

.button {
  padding: 8px 16px;
  background-color: #b45309;
  color: #ffffff;
}

.book-card {
  padding: 16px;
  border: 1px solid rgb(63.5294117647%, 29.2941176471%, 3.1764705882%);
  border-radius: 8px;
}
.book-card__price {
  color: #b91c1c;
}

.cart {
  padding: 16px;
  border: 1px solid rgb(63.5294117647%, 29.2941176471%, 3.1764705882%);
  border-radius: 8px;
  width: 33.3333333333%;
}

The button is brown, .button appears once, and the datepicker uses Bookstore's brand color and corner radius. No warnings at all. The datepicker's $brand now lives only in its own module; it cannot overwrite Bookstore's $brand. Configuration only works for variables declared with !default; try to change the design values from outside and you get:

@use "../modern/abstracts/tokens" with ($brand: #1d4ed8);
Error: This variable was not declared with !default in the @used module.
  ╷
1 │ @use "../modern/abstracts/tokens" with ($brand: #1d4ed8);
  │                                         ^^^^^^^^^^^^^^^
  ╵
  errors/not-default.scss 1:41  root stylesheet

A module is loaded with a single configuration for the whole compilation, so with() has to be given in the entry file, when the module is loaded for the first time. Angular Material, loaded with @use '@angular/material' as mat and configured through mixins such as mat.theme(), is built on the same system; we will use it for theming in the next post.

From Global Functions to Modules: sass-migrator

Instead of migrating by hand, I ran the Sass team's migrator on a copy of the old tree. --migrate-deps migrates every file the entry file loads, too:

npm install --save-dev --save-exact sass-migrator@2.7.0
npx sass-migrator module --migrate-deps --verbose 04/migrated/main.scss
Migrating 04/migrated/_mixins.scss
Migrating 04/migrated/vendor/_datepicker.scss
Migrating 04/migrated/_buttons.scss
Migrating 04/migrated/_book-card.scss
Migrating 04/migrated/_cart.scss
Migrating 04/migrated/main.scss

Three files from the result:

// _mixins.scss
@use "sass:color";
@use "variables";

@mixin card-surface {
    padding: variables.$space;
    border: 1px solid color.adjust(variables.$brand, $lightness: -10%);
    border-radius: variables.$radius;
}

// _cart.scss
@use "sass:math";
@use "buttons";
@use "mixins";

.cart {
    @include mixins.card-surface;
    width: math.percentage(1 / 3);
}

// vendor/_datepicker.scss
@use "../variables";

// third-party partial copied into the repo years ago
variables.$brand: #2563eb;
variables.$radius: 2px;

.datepicker {
    border-radius: variables.$radius;
    border: 1px solid variables.$brand;
}

The migrator did four things: it added the @use rules each file needs (no more order dependency), namespaced the members, and turned darken() into its exact equivalent color.adjust(..., $lightness: -10%), and map-get() and percentage() into map.get() and math.percentage(). The compiled output is the same as before, except that .button is now written once.

Watch the datepicker. The migrator has to preserve behavior; in the old code the datepicker overwrote the global $brand, and in the new code it assigns to the variables module's variable with variables.$brand: #2563eb;. The bug is still there, but now it is visible: you can plainly read that one file writes to another module's variable. Lines like these have to be fixed by hand after the migrator; the !default and with() version above is that fix. 1 / 3 also stayed as it was and still triggers the slash-div warning; sass-migrator division fixes that, and we will use it in post 07 of the series.

The color.adjust() the migrator chose for darken() gives exactly the same result, but the Sass documentation recommends color.scale() in most cases: adjust subtracts a fixed 10 points of lightness (an already dark color can go all the way to black), while scale darkens by 10 percent of the remaining distance and behaves more predictably for very dark or very light colors. That is why the new -border-color() uses scale.

The Module System in Angular: includePaths and CSS-free abstracts

In bookstore-web we wrote @use "../styles/breakpoints" as bp; in the previous post. Relative paths break whenever a component folder moves. In Angular, stylePreprocessorOptions.includePaths in angular.json solves this; the folders listed there are added to Sass's load paths:

"options": {
  "styles": ["src/styles.scss"],
  "stylePreprocessorOptions": {
    "includePaths": ["src/styles"]
  }
}

The style tree now looks like this: under src/styles/abstracts/ there are _tokens.scss (design values), _breakpoints.scss (the map and the from() mixin) and an _index.scss that exposes them; src/styles/_utilities.scss generates the spacing classes.

// src/styles/abstracts/_index.scss
// abstracts produce no CSS: only variables, functions and mixins
@forward "tokens";
@forward "breakpoints";

The global styles.scss and every component start with the same line:

@use "abstracts" as *;

Without includePaths the same build stops with this error:

✘ [ERROR] Error: Can't find stylesheet to import.
  ╷
1 │ @use "abstracts" as *;
  │ ^^^^^^^^^^^^^^^^^^^^^
  ╵

Before and after the move, the name of the global stylesheet produced by ng build stayed the same (styles-73WT35JD.css, 793 bytes); since Angular puts a hash of the content in the file name, that means the global CSS did not change by a single byte.

The most important rule here is the comment line in _index.scss: abstracts must not produce CSS. Angular compiles each component's stylesheet separately; Sass's "a module once" rule holds within a single compilation. If a file that produces CSS rules gets into abstracts, every component that @uses it gets its own copy of that CSS. I tried it: I added @forward "../utilities"; to _index.scss and built. Two components in Bookstore use abstracts (App and BookCard):

main-ZA2LCQVS.js    | main          | 127.21 kB |                38.81 kB
main-NKAADIWN.js    | main          | 129.35 kB |                39.00 kB

The 25 utility rules were copied into two components and the JavaScript bundle grew by 2.1 kB. Worse, these copies are useless: emulated encapsulation rewrites them into component-specific selectors like .m-0[_ngcontent-%COMP%], so each copy works only in its own component's template. In a project with a hundred components, the same mistake means a hundred copies. Utilities and other global CSS should be loaded only from styles.scss.

In Production

Check automatically that abstracts produce no CSS. The simplest way is to verify in CI that the output of npx sass --load-path=src/styles src/styles/abstracts/_index.scss is empty; a one-line check that catches the duplication mistake above before it grows.

Migrate in small steps. Run sass-migrator module on the whole tree in a branch first, compare the compiled CSS with the old one using diff, and then make the with() and private member fixes by hand. In Bookstore the only difference was the duplicate rules disappearing; if your project has an override that relies on order, the diff will show it.

Count @import warnings instead of silencing them. In Dart Sass 2.0 @import will still work and remain a deprecation; it is removed in Dart Sass 3.0. That window is time to migrate in a planned way, not a reason to wait. How to deal with @import warnings coming from libraries is covered in post 07 of the series.

Common Pitfalls

Loading everything with as *. When three libraries and your own abstracts are all loaded with as * and two of them expose a mixin with the same name, the build breaks; worse, someone reading the code has no idea where @include theme comes from.

Mistaking @forward for @use. Writing @forward "tokens" in _index.scss and trying to use $brand in the same file gives an "Undefined variable" error; to use a forwarded module inside the file you need a separate @use.

Merging the migrator's output without reviewing it. The migrator preserves behavior, bad behavior included. Lines that assign to another module's variable, like variables.$brand: … in the datepicker example, are the first thing to search for after running it.

Loading the global stylesheet into a component with @use in Angular. A line like @use "styles" copies all the global CSS into that component; a budget warning (anyComponentStyle) is usually the first symptom.

A Small Experiment

★☆☆ In 04/modern/abstracts/_index.scss, use show instead of hide and expose only card-surface. Explain why components/_buttons.scss fails, and fix it.

★★☆ On a fresh copy of 04/legacy, run sass-migrator module and then sass-migrator division. Compare the old and new compiled output with diff; write down the reason for every line that differs.

★★★ Write an npm script (check:abstracts) in bookstore-web that checks abstracts produce no CSS: it should compile _index.scss with the sass JavaScript API and exit with a non-zero code if the output is not empty. Then deliberately add a CSS rule to _index.scss and show that the script catches it.

In the next post we use Sass for theming: a mixin that generates custom properties under :root and .dark from a token map, switching themes at runtime, light-dark(), and an Angular Material M3 theme with Bookstore's brand colors.