Your country

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

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

CSS  Module 19 – Sass modules, migrating off @import and Sass in production

Sass modules: @use, @forward and configuration

Load Sass partials and built-in modules with @use, give a library one entry point with @forward, configure it once, and load npm packages by pkg: URLs.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and Dart Sass 1.105.1
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Load files and built-in modules with @use, namespaces, as * and private members
  • Build a library entry point with @forward, show, hide and prefixes, plus index files
  • Configure a library with !default and @use ... with (), and load packages through pkg: URLs
What you’ll learn

What you will learn

  • Load files and built-in modules with @use, namespaces, as * and private members
  • Build a library entry point with @forward, show, hide and prefixes, plus index files
  • Configure a library with !default and @use ... with (), and load packages through pkg: URLs
On this page

A Sass module is a stylesheet that another stylesheet loads with @use. Modules differ from the old @import in three ways. Sass evaluates each module once per compilation and includes its CSS once, however many files load it. Everything a module defines (its variables, functions and mixins, together called its members) is reached through a namespace, as in tokens.$brand, so short names never collide. And members whose names start with - or _ stay private to their file. @forward builds on this: it passes another module’s members on to whoever loads the current file, so a library can offer one entry point and decide, with prefixes and show or hide lists, what is public. A library marks its settings with !default, and an app sets them once with @use "library" with (…). Dart Sass has supported modules since version 1.23.0. Every example below was compiled with Dart Sass 1.105.1 on Node.js 24.21.0, and its output is recorded under it.

The lesson assumes you can already write SCSS with variables, nesting, mixins, functions and maps.

Loading a module with @use

@use "<url>" loads the stylesheet at that URL as a module. The URL leaves out the file extension and the underscore of a partial, so @use "tokens" finds _tokens.scss (or tokens.scss, tokens.sass or tokens.css) next to the current file. Only when nothing matches there does Sass look in the load paths. URLs use forward slashes on every operating system, and they are case-sensitive, so Tokens and tokens would be two modules.

The module’s members are then reached through its namespace, which is the last part of the URL: @use "src/tokens" gives tokens.$brand, tokens.space(md) and @include tokens.focus-ring. Three variations are worth knowing:

  • as t chooses another namespace, which you need when two URLs end in the same name;
  • as * drops the namespace, so the members read as if the current file had defined them. Keep it for your own small modules: a library that adds a member later can then collide with one of yours;
  • sass: URLs load the built-in modules: sass:math, sass:map, sass:color, sass:list, sass:string, sass:selector and sass:meta.

@use rules come first in a file. Only @forward rules and variable declarations (for configuring modules) may stand above them, and a style rule above a @use stops the compile. The four files below make a small project: a token module, two components that load it under different namespaces, and an entry point that loads all three and sass:math. The token module prints a @debug message every time Sass evaluates it.

An entry point, a token module and two components

load/app.scss

// The entry point: a built-in module, the tokens without a namespace, two components.
@use "sass:math";
@use "tokens" as *;
@use "button";
@use "card";

.stack {
  display: grid;
  gap: math.div($space, 2);
}

load/_tokens.scss

// Design tokens shared by the components.
@use "sass:map";

$brand: #0b5fff;
$space: 8px;

// Private: a name that starts with - (or _) is visible only inside this file.
$-steps: (sm: 1, md: 2, lg: 3);

@function space($size) {
  @return $space * map.get($-steps, $size);
}

// Printed each time Sass evaluates this file.
@debug "evaluating _tokens.scss";

:root {
  --brand: #{$brand};
}

load/_button.scss

// Loads the tokens under their default namespace, "tokens".
@use "tokens";

.button {
  padding: tokens.space(sm) tokens.space(md);
  background: tokens.$brand;
}

load/_card.scss

// The same module under a shorter namespace.
@use "tokens" as t;

.card {
  padding: t.space(lg);
  border: 1px solid t.$brand;
}

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"
  }
}
Compiling app.scss JavaScript · load/compile.mjs
// Compiles app.scss with Dart Sass and prints the CSS, then the files Sass loaded.
// Run it in this folder after `npm install` in the examples folder: node compile.mjs
import { relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as sass from 'sass';

const debug = [];
const result = sass.compile('app.scss', {
  logger: { debug: (message, { span }) => debug.push(`${relative('.', fileURLToPath(span.url))}: ${message}`) },
});

console.log(result.css);
console.log('\n@debug messages:');
for (const line of debug) console.log(`  ${line}`);
console.log('Files loaded:');
for (const url of result.loadedUrls) console.log(`  ${relative('.', fileURLToPath(url))}`);
console.log(`:root rules in the CSS: ${result.css.match(/:root/g)?.length ?? 0}`);
console.log(`Compiled with Dart Sass ${sass.info.split('\t')[1]}`);

Output

:root {
  --brand: #0b5fff;
}

.button {
  padding: 8px 16px;
  background: #0b5fff;
}

.card {
  padding: 24px;
  border: 1px solid #0b5fff;
}

.stack {
  display: grid;
  gap: 4px;
}

@debug messages:
  _tokens.scss: evaluating _tokens.scss
Files loaded:
  app.scss
  _tokens.scss
  _button.scss
  _card.scss
:root rules in the CSS: 1
Compiled with Dart Sass 1.105.1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compile.mjs

Three files load _tokens.scss, yet the @debug message appears once and the CSS has one :root rule. Sass evaluated the module once and handed the same module to every file that asked for it, so its CSS cannot be duplicated and every component saw the same values. $-steps never left the token file: space() reads it on the module’s behalf. The list of loaded files also shows that sass:math is not a file at all. Built-in modules are part of the compiler, and app.scss can use math.div() without anything on disk.

To run the examples on your own computer, put the package.json above in an empty folder, run npm install, copy the example files into it, and run each script with node in its folder.

CSS Toolkit Paste a list of SCSS $name: value tokens and get the matching CSS custom properties for a :root rule.

Keeping members private

A member whose name starts with - or _ is private. It works normally inside its own file and does not exist for anyone who loads the module: reading one from outside stops the compile with “Private members can’t be accessed from outside their modules”, one of the three errors recorded further down. Private members let a module change its internals without breaking the files that use it, which is why a library should prefer them to long, unlikely names.

File privacy is sometimes too strict. A library’s components may need a helper that its users should not call. Make that helper package-private instead: leave it public in its own file and keep it out of the library’s entry point. Users who load the library through the entry point never see it, while the library’s own files load the helper’s module directly. The next section shows how an entry point decides what to pass on.

One entry point with @forward

@forward "<url>" loads a module and makes its public members part of the current module’s API, as if they had been defined there. They are not available inside the forwarding file itself: add a @use for the same URL if you need them there too, and Sass still loads the module once. Forwarded modules contribute their CSS, exactly as with @use. A forward takes three options:

  • a prefix, @forward "functions" as fn-*, adds fn- to every forwarded name, so space() reaches the library’s users as fn-space();
  • show with a list of names forwards only those members;
  • hide with a list of names forwards everything except them.

show and hide name variables with their $, and they use the names after the prefix is added: with as fn-*, write hide fn-rem, not hide rem. In Dart Sass 1.105.1, hide rem there hides nothing and raises no error, so check that a hidden member really is gone.

A folder becomes a library when it has an index file: @use "ui" loads ui/_index.scss when ui is a folder, and an index file usually consists of @forward rules. Here is a small design-token library, its index and its three modules:

The ui library: an index file and three modules

ui/_index.scss · library/ui/_index.scss

// The library's entry point: @use "ui" loads this file (an index file of the ui folder).
// It says exactly what the library offers. Forwarded modules come first, so an app's configuration
// reaches the tokens before anything else loads them.
@forward "tokens" show $brand, $radius, $space, focus-ring;
@forward "functions" as fn-*;
@forward "button";

ui/_tokens.scss · library/ui/_tokens.scss

// Settings: an app may change any !default value when it loads the library.
$brand: #0b5fff !default;
$radius: 6px !default;
$space: 0.5rem !default;

// Private to this file: no app can read or configure it.
$-ring-width: 3px;

@mixin focus-ring {
  outline: $-ring-width solid $brand;
  outline-offset: 2px;
}

ui/_functions.scss · library/ui/_functions.scss

@use "sass:math";
@use "tokens";

// space(2) is two spacing units.
@function space($steps) {
  @return tokens.$space * $steps;
}

// rem(18px) is 1.125rem with the browser's default 16px root size.
@function rem($px, $root: 16px) {
  @return math.div($px, $root) * 1rem;
}

ui/_button.scss · library/ui/_button.scss

@use "tokens";
@use "functions" as fn;

.ui-button {
  padding: fn.space(1) fn.space(2);
  border-radius: tokens.$radius;
  background: tokens.$brand;
  color: white;

  &:focus-visible {
    @include tokens.focus-ring;
  }
}
Module graph: shop.scss uses ui/_index.scss, which forwards tokens, functions and button; those modules use the tokens.shop.scss@use "ui" with ($brand: …)ui/_index.scssthe library's entry point_tokens.scss$brand, $radius, $space !default$-ring-width (private)_functions.scssspace(), rem()_button.scss.ui-button (CSS only)@use@forward … show@forward as fn-*@forward@use@use@use as fn

One entry point forwards three modules; every module is evaluated once per compilation

Text description of the diagram

The diagram shows how the files of the ui library load each other when shop.scss is compiled.

  1. shop.scss loads the library with @use "ui" with ($brand: …). Sass finds ui/_index.scss, the index file of the ui folder.
  2. ui/_index.scss forwards three modules: _tokens.scss with show (only $brand, $radius, $space and the focus-ring mixin), _functions.scss with the prefix fn-, and _button.scss, which has no members and only emits CSS.
  3. _functions.scss and _button.scss load _tokens.scss with @use, and _button.scss also loads _functions.scss under the namespace fn.

_tokens.scss is reached three times but evaluated once, with the configuration that shop.scss gave, so every module sees the same $brand. Its private variable $-ring-width never leaves the file.

Read the index as the library’s public API. show lists the four token members the library promises to keep, so anything added to _tokens.scss later stays internal until someone adds it to that list on purpose. The functions arrive with an fn- prefix because names like space() and rem() are generic, and the prefix keeps them apart even for an app that loads the library as *. _button.scss has no members, so forwarding it only adds its CSS. The forwards come first and the index uses none of its own modules. When a file both forwards and uses a module, the Sass documentation advises writing the @forward first, so that a user’s configuration reaches the module before the file’s own @use loads it.

Configuring a library with !default and with ()

A variable declared with !default takes its value only if it has none yet, and that makes it a setting: the code that loads the module may give it another value first. The app writes those values in a with clause, @use "ui" with ($brand: #c2185b, $radius: 0), and the configuration travels through the @forward rules to the module that declares each variable. A variable forwarded with a prefix is configured by its prefixed name. Two apps compile the same library with different settings:

Two apps that load the ui library

library/shop.scss

// The shop: its own brand colour, square corners and roomier spacing.
@use "ui" with (
  $brand: #c2185b,
  $radius: 0,
  $space: 0.75rem
);

.checkout {
  padding: ui.fn-space(3);
  font-size: ui.fn-rem(18px);
}

library/blog.scss

// The blog keeps the library's defaults.
@use "ui";

.post {
  margin-block: ui.fn-space(4);

  &:focus-within {
    @include ui.focus-ring;
  }
}
The two apps, compiled JavaScript · library/build.mjs
// Compiles the two apps that share the ui library, each with its own configuration.
// Run it in this folder after `npm install` in the examples folder: node build.mjs
import * as sass from 'sass';

for (const app of ['shop.scss', 'blog.scss']) {
  const { css } = sass.compile(app);
  console.log(`/* ${app} */\n${css}\n`);
}

Output

/* shop.scss */
.ui-button {
  padding: 0.75rem 1.5rem;
  border-radius: 0;
  background: #c2185b;
  color: white;
}
.ui-button:focus-visible {
  outline: 3px solid #c2185b;
  outline-offset: 2px;
}

.checkout {
  padding: 2.25rem;
  font-size: 1.125rem;
}

/* blog.scss */
.ui-button {
  padding: 0.5rem 1rem;
  border-radius: 6px;
  background: #0b5fff;
  color: white;
}
.ui-button:focus-visible {
  outline: 3px solid #0b5fff;
  outline-offset: 2px;
}

.post {
  margin-block: 2rem;
}
.post:focus-within {
  outline: 3px solid #0b5fff;
  outline-offset: 2px;
}

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node build.mjs

The shop’s three settings reached every place that reads them: the button’s padding (fn.space(1) became 0.75rem), its corners, and both uses of the brand colour, including the focus ring, whose width still comes from the private $-ring-width. The blog compiled the same files with the defaults. Four rules govern configuration:

  • One configuration per module and compilation. A module is evaluated once, so it can be configured only the first time it is loaded, and every later load gets the same values. That is what makes themes work: configure the library once in the entry point, and every component that loads the tokens sees the theme. A light and a dark theme are therefore two entry points, compiled separately, not two with clauses in one file.
  • Only !default variables can be configured. Configuring any other variable fails with “This variable was not declared with !default in the @used module”. The same message appears when you configure a prefixed variable by its old name, which is easy to misread. Keep private variables out of with clauses: Dart Sass 1.92.0 deprecated configuring them, even when they are declared with !default, and Dart Sass 2.0.0 will make it an error.
  • as comes before with: @use "ui" as kit with ($radius: 0).
  • Libraries can set defaults for each other. A wrapper library that writes @forward "ui" with ($radius: 2px !default) changes the default, and its own users can still override it.

A with clause suits a handful of plain settings. For settings held in maps, settings that change after loading, or values that vary per element, the Sass documentation suggests a mixin that stores the settings and a second mixin that emits the styles. Values that must change while the page runs, such as a theme the visitor switches, belong in CSS custom properties, which the cascade can change per element and at any time; Sass’s job is then to write them out once.

Three errors and how to fix them

Most mistakes with modules stop the compile with a precise message. Each of these stylesheets makes one, using the ui library from above:

Three stylesheets with one mistake each

errors/configured-twice.scss

// Loads the tokens directly first, then tries to configure the library that forwards them.
@use "../library/ui/tokens";
@use "../library/ui" with ($brand: #c2185b);

errors/private-member.scss

// Reads a private variable of another module.
@use "../library/ui/tokens";

.note {
  outline-width: tokens.$-ring-width;
}

errors/use-after-rule.scss

// A style rule before the @use rule.
.banner {
  color: navy;
}

@use "../library/ui";
What Dart Sass reports for each one JavaScript · errors/errors.mjs
// Compiles three broken stylesheets and prints the error Dart Sass reports for each one.
// Run it in this folder after `npm install` in the examples folder: node errors.mjs
import * as sass from 'sass';

for (const file of ['configured-twice.scss', 'private-member.scss', 'use-after-rule.scss']) {
  try {
    sass.compile(file);
    console.log(`${file}: compiled (unexpected)`);
  } catch (error) {
    console.log(`=== ${file}\n${error.message}\n`);
  }
}

Output

=== configured-twice.scss
This module was already loaded, so it can't be configured using "with".
  ┌──> ../library/ui/_index.scss
4 │ @forward "tokens" show $brand, $radius, $space, focus-ring;
  │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ new load
  ╵
  ┌──> configured-twice.scss
2 │ @use "../library/ui/tokens";
  │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ original load
3 │ @use "../library/ui" with ($brand: #c2185b);
  │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ configuration
  ╵
  ../library/ui/_index.scss 4:1  @use
  configured-twice.scss 3:1      root stylesheet

=== private-member.scss
Private members can't be accessed from outside their modules.
  ╷
5 │   outline-width: tokens.$-ring-width;
  │                  ^^^^^^^^^^^^^^^^^^^
  ╵
  private-member.scss 5:18  root stylesheet

=== use-after-rule.scss
@use rules must be written before any other rules.
  ╷
6 │ @use "../library/ui";
  │ ^^^^^^^^^^^^^^^^^^^^
  ╵
  use-after-rule.scss 6:1  root stylesheet

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node errors.mjs

  • Already loaded. configured-twice.scss loads the token module directly before it configures the library that forwards the same module, so the tokens were already evaluated with their defaults. Sass marks both loads and the configuration. Fix it by putting the configured @use first, in the entry point, before anything else loads the module.
  • Private member. The variable is part of the module’s internals. Use the public member made for the job, the focus-ring mixin here, or, if users really need the value, make it public on purpose and add it to the index’s show list.
  • Order. Move the @use rule to the top of the file. Comments may stand above it, and so may variables that a with clause uses.

Loading packages with pkg: URLs

Libraries installed with npm live in node_modules, and stylesheets have long reached them through a load path (--load-path=node_modules) or a bundler’s ~ prefix. A pkg: URL replaces both with one portable form: @use "pkg:acme-tokens" asks the package manager’s own rules where the package’s Sass lives. Dart Sass 1.71.0 added a pkg: importer that follows Node.js package resolution. It is off by default: add new NodePackageImporter() to the importers option of the JavaScript API, or pass --pkg-importer=node on the command line.

The importer finds the package in the nearest node_modules folder, as Node.js would, and reads its package.json in this order:

  1. the exports field, with the conditions sass, style and default, taking the first key that matches, so default belongs last;
  2. when the URL has no subpath, the sass or style field;
  3. an index file at the package’s root.

Package authors write whole file names in exports ("./colors.scss"), and users load pkg:acme-tokens/colors without the extension or the underscore of a partial. The script below installs a small local package and loads three of its exports, then loads Bootstrap 5.3.8, whose package.json has a sass field and no Sass exports:

A package with Sass exports, and a site that loads it

acme-tokens/package.json · packages/acme-tokens/package.json

{
  "name": "acme-tokens",
  "version": "1.0.0",
  "private": true,
  "exports": {
    ".": {
      "sass": "./scss/_index.scss",
      "default": "./dist/tokens.css"
    },
    "./colors.scss": {
      "sass": "./scss/_colors.scss"
    },
    "./themes/*.scss": {
      "sass": "./scss/themes/*.scss"
    }
  }
}

packages/site.scss

// The root export, a subpath export and a subpath pattern of one package.
@use "pkg:acme-tokens" as acme;
@use "pkg:acme-tokens/colors";
@use "pkg:acme-tokens/themes/dark";

.page {
  color: colors.$ink;
  border-radius: acme.$radius;
  accent-color: acme.$accent;
}
Resolving pkg: URLs JavaScript · packages/pkg.mjs
// Loads packages through pkg: URLs with Dart Sass's Node.js package importer.
// Run it in this folder after `npm install` in the examples folder: node pkg.mjs
import { cpSync, rmSync } from 'node:fs';
import { relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as sass from 'sass';

// The same as `npm install ./acme-tokens`, without the network: put the package into node_modules.
rmSync('node_modules/acme-tokens', { recursive: true, force: true });
cpSync('acme-tokens', 'node_modules/acme-tokens', { recursive: true });

const importers = [new sass.NodePackageImporter()];
const site = sass.compile('site.scss', { importers });
console.log(site.css);
console.log('\nFiles loaded for site.scss:');
for (const url of site.loadedUrls) console.log(`  ${relative('.', fileURLToPath(url))}`);

// Bootstrap 5.3.8 has no "exports" for Sass, but its package.json has "sass": "scss/bootstrap.scss".
const bootstrap = sass.compileString('@use "pkg:bootstrap";', { importers, quietDeps: true, style: 'compressed' });
const entry = fileURLToPath(bootstrap.loadedUrls[0]);
console.log(`\npkg:bootstrap loads ${entry.slice(entry.lastIndexOf('node_modules/'))}`);
console.log(`and ${bootstrap.loadedUrls.length - 1} more files: ${bootstrap.css.length.toLocaleString('en')} bytes of compressed CSS`);

Output

.theme-dark {
  color: #f9fafb;
  background: #111827;
}

.page {
  color: #111827;
  border-radius: 4px;
  accent-color: #6d28d9;
}

Files loaded for site.scss:
  site.scss
  node_modules/acme-tokens/scss/_index.scss
  node_modules/acme-tokens/scss/_colors.scss
  node_modules/acme-tokens/scss/themes/_dark.scss

pkg:bootstrap loads node_modules/bootstrap/scss/bootstrap.scss
and 86 more files: 233,473 bytes of compressed CSS

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pkg.mjs

The root export, the ./colors.scss subpath and the ./themes/*.scss pattern all resolved to files inside the package, and only the dark theme added CSS, because it is the only module with style rules. pkg:bootstrap went through the sass field to scss/bootstrap.scss, which loaded 86 more files. quietDeps: true kept Bootstrap’s deprecation warnings off the screen: Bootstrap 5.3.8 still uses @import internally, which the next lesson, on migrating off @import, deals with.

Modules as values: meta.load() and meta.css()

@use URLs are fixed strings, so a reader can always tell where a member comes from. In @use "themes/#{$name}" Sass does not substitute the variable, and the compile fails. For the rare stylesheet that must choose a module while it runs, Dart Sass 1.105.0 added first-class modules: meta.load($url) loads a module as a value without emitting its CSS or adding its members to the file, and the meta.css($module) mixin includes the module’s CSS where you call it. Functions such as meta.module-variables() read a module value’s members. The older meta.load-css($url) mixin, available since Dart Sass 1.23.0, is a shorthand for meta.css(meta.load($url)).

Previews of two themes, loaded by name

first-class/preview.scss

// Dart Sass 1.105.0 and later: a module as a value. The URL may come from a variable.
@use "sass:map";
@use "sass:meta";

$names: light, dark;

@each $name in $names {
  $theme: meta.load("themes/#{$name}");

  .preview-#{$name} {
    // The module's CSS, included here rather than where it was loaded.
    @include meta.css($theme);
    border-color: map.get(meta.module-variables($theme), "ink");
  }
}

themes/_light.scss · first-class/themes/_light.scss

$ink: #1f2937;
$paper: #ffffff;

.theme {
  color: $ink;
  background: $paper;
}

themes/_dark.scss · first-class/themes/_dark.scss

$ink: #f3f4f6;
$paper: #111827;

.theme {
  color: $ink;
  background: $paper;
}
Compiling preview.scss JavaScript · first-class/first-class.mjs
// Compiles a stylesheet that loads modules as values with meta.load() and places their CSS with meta.css().
// Needs Dart Sass 1.105.0 or later. Run it in this folder after `npm install` in the examples folder.
import * as sass from 'sass';

console.log(sass.compile('preview.scss').css);

Output

.preview-light .theme {
  color: #1f2937;
  background: #ffffff;
}
.preview-light {
  border-color: #1f2937;
}

.preview-dark .theme {
  color: #f3f4f6;
  background: #111827;
}
.preview-dark {
  border-color: #f3f4f6;
}

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node first-class.mjs

Each theme’s CSS landed inside its own preview selector, and each theme’s $ink was read as a map entry. Like @use, meta.load() evaluates a module once per compilation and cannot configure a module that is already loaded. These functions are new, so a library that must compile with older Dart Sass versions should keep to @use and meta.load-css().

Exercise: turn loose partials into a configurable kit

The kit folder holds three partials that already use @use: colours with a tint() helper, a spacing scale and an alert component. Two apps load the kit the way its users will, and they must not change:

The two apps and the kit as given

exercise/app-a.scss

// An app with its own colours and a larger spacing unit. Do not change this file.
@use "kit" with (
  $primary: #0f9d58,
  $danger: #b3261e,
  $space-unit: 6px
);

.toolbar {
  gap: kit.space-step(2);
  color: kit.$primary;
}

exercise/app-b.scss

// An app that keeps the kit's defaults. Do not change this file.
@use "kit";

.toolbar {
  gap: kit.space-step(2);
  color: kit.$danger;
}

kit/_colors.scss · exercise/kit/_colors.scss

$primary: #1a73e8;
$danger: #c5221f;

// How much of the colour tint() keeps. Only this file should read it.
$tint-amount: 12%;

// The kit's components use tint(); apps should not be able to call it.
@function tint($color) {
  @return color-mix(in srgb, $color $tint-amount, white);
}

kit/_spacing.scss · exercise/kit/_spacing.scss

$unit: 4px;

@function step($n) {
  @return $unit * $n;
}

kit/_alert.scss · exercise/kit/_alert.scss

@use "colors";
@use "spacing";

.alert {
  padding: spacing.step(3) spacing.step(4);
  border-left: spacing.step(1) solid colors.$primary;
  background: colors.tint(colors.$primary);
}

.alert--danger {
  border-left-color: colors.$danger;
  background: colors.tint(colors.$danger);
}

Change only the kit folder, so that:

  1. @use "kit" loads the whole kit through an index file;
  2. the spacing members reach apps with the prefix space-, and the colour members without a prefix;
  3. $tint-amount is private to _colors.scss, and apps cannot call tint(), while the alert still uses it;
  4. $primary, $danger and $unit are settings that an app can configure.

Run node check.mjs in the exercise folder. As given, all seven of its tests fail:

The sample tests, run on the kit as given JavaScript · exercise/check.mjs
// Sample tests for the exercise: compiles the two apps and probes what an app can reach.
// Run it in this folder after `npm install` in the examples folder: node check.mjs
import * as sass from 'sass';

const APP_A = `.alert {
  padding: 18px 24px;
  border-left: 6px solid #0f9d58;
  background: color-mix(in srgb, #0f9d58 12%, white);
}

.alert--danger {
  border-left-color: #b3261e;
  background: color-mix(in srgb, #b3261e 12%, white);
}

.toolbar {
  gap: 12px;
  color: #0f9d58;
}`;

const APP_B = `.alert {
  padding: 12px 16px;
  border-left: 4px solid #1a73e8;
  background: color-mix(in srgb, #1a73e8 12%, white);
}

.alert--danger {
  border-left-color: #c5221f;
  background: color-mix(in srgb, #c5221f 12%, white);
}

.toolbar {
  gap: 8px;
  color: #c5221f;
}`;

/** The compiled CSS of a file, or the first line of its error. */
function compile(file) {
  try {
    return { css: sass.compile(file).css };
  } catch (error) {
    return { error: error.message.split('\n')[0] };
  }
}

/** Compiles a probe stylesheet written in this folder; returns the first line of its error, or null. */
function probe(source) {
  try {
    sass.compileString(source, { url: new URL('probe.scss', import.meta.url) });
    return null;
  } catch (error) {
    return error.message.split('\n')[0];
  }
}

const a = compile('app-a.scss');
const b = compile('app-b.scss');
const tests = [
  ['app-a.scss compiles', !a.error, a.error],
  ['app-a.scss gives the expected CSS', a.css === APP_A, a.css === undefined ? 'nothing to compare' : 'the CSS differs'],
  ['app-b.scss compiles', !b.error, b.error],
  ['app-b.scss gives the expected CSS', b.css === APP_B, b.css === undefined ? 'nothing to compare' : 'the CSS differs'],
  ['an app cannot call kit.tint()', probe('@use "kit"; .x { color: kit.tint(red); }') === 'Undefined function.', 'kit.tint() is reachable, or kit does not load'],
  ['other files cannot read the tint amount', !!probe('@use "kit/colors"; .x { a: colors.$tint-amount; }') && !!probe('@use "kit/colors"; .x { a: colors.$-tint-amount; }'), 'colors.$tint-amount is readable'],
  ['the alert CSS appears once in app-a', a.css?.match(/^\.alert \{/gm)?.length === 1, `found ${a.css?.match(/^\.alert \{/gm)?.length ?? 0} times`],
];

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  app-a.scss compiles (Can't find stylesheet to import.)
FAIL  app-a.scss gives the expected CSS (nothing to compare)
FAIL  app-b.scss compiles (Can't find stylesheet to import.)
FAIL  app-b.scss gives the expected CSS (nothing to compare)
FAIL  an app cannot call kit.tint() (kit.tint() is reachable, or kit does not load)
FAIL  other files cannot read the tint amount (colors.$tint-amount is readable)
FAIL  the alert CSS appears once in app-a (found 0 times)

0 of 7 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: file privacy and package privacy are different tools. Which one keeps tint() usable for _alert.scss but out of reach for the apps?

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 stylesheet starts with @use "theme/colors";. How does it read that module's $ink variable?

    Choose one answer.

    Show the answer to question 1

    Answer: colors.$ink

    The default namespace is the last part of the URL, without an extension or underscore. Only as * removes the namespace, and as name replaces it.

  2. Question 2 of 6 _tokens.scss contains a :root rule. app.scss, _button.scss and _card.scss each load it with @use, and app.scss also loads the two components. How many :root rules does compiling app.scss produce?

    Choose one answer.

    Show the answer to question 2

    Answer: One

    Sass evaluates a module once per compilation and includes its CSS once, however many files load it. The recorded run of the load example shows one :root rule and one @debug message from the token file.

  3. Question 3 of 6 An index file forwards a module like this. Which line in a file that does @use "ui"; compiles?

    Read the code, then choose one answer.

    // ui/_index.scss
    @forward "functions" as fn-* hide fn-rem;
    
    // ui/_functions.scss
    @use "sass:math";
    @function space($n) { @return 0.5rem * $n; }
    @function rem($px) { @return math.div($px, 16px) * 1rem; }
    Show the answer to question 3

    Answer: .a { padding: ui.fn-space(2); }

    The prefix renames every forwarded member, so space() becomes fn-space(). hide uses the prefixed name, so fn-rem() is not forwarded at all, under either name.

  4. Question 4 of 6 Which of these keep a helper function out of reach of the apps that load a library through @use "lib"?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • Leaving it out of lib/_index.scss with hide helper on the @forward of its module
    • Naming it -helper in its own file

    A leading - or _ makes a member private to its file, which also hides it from the library's other files. Hiding it in the entry point keeps it usable inside the library but invisible to apps. !default applies to variables and makes them configurable, which is the opposite of private.

  5. Question 5 of 6 An entry point contains @use "ui/tokens"; and then @use "ui" with ($brand: red);, where ui/_index.scss forwards tokens. What happens?

    Choose one answer.

    Show the answer to question 5

    Answer: The compile fails: the module was already loaded, so it cannot be configured

    A module is evaluated once per compilation, the first time it is loaded, and its configuration is fixed then. Configure it in the first @use that reaches it.

  6. Question 6 of 6 Put the places the Node.js package importer checks for pkg:library (no subpath) in the order it checks them.

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

    Show the answer to question 6

    Answer:

    1. The "exports" field, with the sass, style and default conditions
    2. The "sass" or "style" field
    3. An index file at the package root

    Exports come first and are the recommended way for packages to expose Sass entry points. The sass and style fields only apply when the URL has no subpath, and the index file is the last fallback.

Interview questions

What is the difference between @use and @import in Sass?

@import copies a file’s members into one global scope and emits its CSS every time the file is imported. @use loads the file as a module instead: Sass evaluates it once per compilation and includes its CSS once, however many files load it. Its members are reached through a namespace, such as tokens.$brand, so short names cannot collide and a reader can tell where each one comes from, and members whose names start with - or _ stay private to their file. A module is configured once, through its !default variables and a with clause, instead of by setting global variables before an import. A @use rule loads one URL, must come before other rules and cannot be nested inside a style rule. @import has been deprecated since Dart Sass 1.80.0 and is due to be removed in Dart Sass 3.0.0.

Key takeaways

  • @use "tokens" loads _tokens.scss as a module: evaluated once per compilation, its CSS included once, its members reached as tokens.$name, tokens.fn() and @include tokens.mixin.
  • as name renames the namespace, as * removes it, and sass:math, sass:map and the other built-in modules load the same way.
  • Members starting with - or _ are private to their file; keep a helper package-private by leaving it out of the library’s entry point.
  • @forward passes members on: add a prefix with as fn-*, choose members with show or hide (using the prefixed names), and give a folder an _index.scss so @use "folder" loads it.
  • !default turns a variable into a setting; @use "library" with (…) configures it once, the first time the module is loaded, and prefixed variables are configured by their prefixed names.
  • One configuration per module and compilation: two themes are two entry points.
  • pkg: URLs load npm packages through exports (conditions sass, style, default), then the sass or style field, once NodePackageImporter or --pkg-importer=node is on.
  • meta.load() and meta.css() (Dart Sass 1.105.0) load a module by a computed URL and place its CSS where you choose.

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.