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.
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 tchooses 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:selectorandsass: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.
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"
}
} // 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.
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-*, addsfn-to every forwarded name, sospace()reaches the library’s users asfn-space(); showwith a list of names forwards only those members;hidewith 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:
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;
}
} 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.
- shop.scss loads the library with @use "ui" with ($brand: …). Sass finds ui/_index.scss, the index file of the ui folder.
- 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.
- _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:
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;
}
} // 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
withclauses in one file. - Only
!defaultvariables 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 ofwithclauses: 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. ascomes beforewith:@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:
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"; // 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.scssloads 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@usefirst, 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-ringmixin here, or, if users really need the value, make it public on purpose and add it to the index’sshowlist. - Order. Move the
@userule to the top of the file. Comments may stand above it, and so may variables that awithclause 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:
- the
exportsfield, with the conditionssass,styleanddefault, taking the first key that matches, sodefaultbelongs last; - when the URL has no subpath, the
sassorstylefield; - 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:
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;
} // 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)).
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;
} // 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:
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:
@use "kit"loads the whole kit through an index file;- the spacing members reach apps with the prefix
space-, and the colour members without a prefix; $tint-amountis private to_colors.scss, and apps cannot calltint(), while the alert still uses it;$primary,$dangerand$unitare settings that an app can configure.
Run node check.mjs in the exercise folder. As given, all seven of its tests fail:
// 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.
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.scssas a module: evaluated once per compilation, its CSS included once, its members reached astokens.$name,tokens.fn()and@include tokens.mixin.as namerenames the namespace,as *removes it, andsass:math,sass:mapand 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. @forwardpasses members on: add a prefix withas fn-*, choose members withshoworhide(using the prefixed names), and give a folder an_index.scssso@use "folder"loads it.!defaultturns 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 throughexports(conditionssass,style,default), then thesassorstylefield, onceNodePackageImporteror--pkg-importer=nodeis on.meta.load()andmeta.css()(Dart Sass 1.105.0) load a module by a computed URL and place its CSS where you choose.
References
- @use (Sass team (sass-lang.com))
- @forward (Sass team (sass-lang.com))
- Built-in modules (Sass team (sass-lang.com))
- sass:meta: meta.load(), meta.css() and meta.load-css() (Sass team (sass-lang.com))
- NodePackageImporter (JavaScript API) (Sass team (sass-lang.com))
- Breaking change: private configuration (Sass team (sass-lang.com))
- Dart Sass changelog (1.23.0, 1.71.0, 1.92.0, 1.105.0) (Sass team (GitHub))
- Node.js packages: package entry points and conditional exports (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress