Sass Modules in Practice: Replacing @import Without Breaking Your Build
Migrating from Sass @import to @use and @forward fixes global namespace pollution and enables themeable configuration, but it requires Dart Sass and careful namespace management.
06 Jun 2026, 12:57 UTC

The Global Namespace Problem
When two Sass libraries both define a variable named $primary, the one loaded last wins. There is no error, no warning, and no way to tell which file actually supplied the value. If you have ever traced a mysterious color back through a chain of @import statements, you already understand why Sass moved to a module system. The fix is @use and @forward: they make every dependency explicit, scope variables and mixins to a namespace, and let you configure a library at the point where you load it.
This article walks through the practical shift from @import to Sass modules. We will look at how namespaces eliminate collisions, how configuration works, where @forward fits in a library's public API, and what you need to verify before deleting the last @import from your codebase.
The @import Trap
@import in Sass behaves less like a modern module system and more like text concatenation. Every variable, mixin, and function from the imported file lands in the global scope. If _buttons.scss defines $border-radius and _cards.scss also defines $border-radius, the final value depends entirely on import order. Renaming a file or moving an @import can silently change the output. There is also no clean way to configure a library: you either copy its variables into your own file and hope the load order is right, or you fork the library.
Native CSS @import statements—those pointing to .css files or using url()—pass through untouched. Sass only intercepts imports that resolve to .scss or .sass files. The confusion arises because both syntaxes look similar in source code.
Namespaces and Private Members
@use loads a Sass file once and attaches its members to a namespace derived from the file name. If you write @use 'src/colors';, you access $text as colors.$text. The default namespace is the final path segment, so src/colors and theme/colors both become colors. When two files share a basename, assign an explicit alias:
@use 'src/colors' as src;
@use 'theme/colors' as theme;
Members prefixed with - or _ are private and stay invisible outside the module. This lets library authors mark internal helpers without relying on naming conventions alone.
Configuring a Module at Use Time
One of the strongest reasons to adopt modules is configuration. A module can declare a variable with !default, and the consuming stylesheet can override it inside the @use rule using the with keyword. The override happens before the module is evaluated, so every calculation inside the module sees the new value.
Here is a minimal project layout:
_config.scss
main.scss
_config.scss:
$primary: blue !default;
$radius: 4px !default;
main.scss:
@use 'config' with (
$primary: red,
$radius: 8px
);
.button {
color: config.$primary;
border-radius: config.$radius;
}
Run the compiler from your project root:
npx sass main.scss
Expected check: the generated CSS should contain color: red; and border-radius: 8px;. If you see blue, the with block was ignored or the variable lacked !default. Risk: this command requires Dart Sass. Legacy Node Sass and LibSass do not support @use, so a build still pointing at node-sass will fail.
Forwarding as an API Boundary
@forward does not load a module for use; it re-exports members from another file. A library can keep its internal files private and expose a single entry point:
// _index.scss
@forward 'src/colors';
@forward 'src/spacing';
Consumers then write @use 'library' as lib; and access lib.$text or lib.$spacer. You can narrow the surface with show and hide:
@forward 'src/colors' show $primary, $secondary;
This prevents internal variables from leaking into the public API. The trade-off is brittleness: every show or hide list is a contract. If you rename an internal variable, you must update the forward list, and downstream code breaks. Prefer explicit show lists over hide lists so the public surface is intentional.
Trade-offs and Limitations
Modules are not a drop-in replacement. Three issues matter most in practice.
First, implementation support. Only Dart Sass understands @use and @forward. If your build pipeline still resolves sass to node-sass, the migration will fail immediately. Switch to the sass npm package before running any conversion.
Second, circular dependencies. @use is stricter than @import: if a.scss uses b.scss and b.scss uses a.scss, Dart Sass throws an error. @import tolerated cycles by concatenating files in order. The fix is to extract shared code into a third module that both depend on.
Third, namespace collisions. Two files with the same basename produce the same default namespace. This is not an error, but it makes references ambiguous. Always alias when paths overlap:
@use 'utils/colors' as utils;
@use 'theme/colors' as theme;
A fourth, smaller limitation: configuration only works on !default variables. You cannot override a hard-coded value at use time.
Migrating Safely
Dart Sass ships a migrator that automates much of the mechanical work. From your project root:
npx sass-migrator module main.scss
Run it on a branch, then compile the result. The migrator handles namespace insertion and basic syntax changes, but it cannot decide whether a global variable was meant to be shared or accidentally leaked. Review every diff.
Actionable Next Step
Start with one leaf stylesheet that has no internal @import statements. Convert it to @use, compile, and confirm the CSS is byte-for-byte identical except for intentional changes. Once that passes, move inward. The goal is not to eliminate every @import on day one; it is to make each dependency explicit enough that a future rename cannot silently change your design system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.