Generating Layer Imports With a Sass Build Step
@import url("x.css") layer(components) has no Sass equivalent. @use is a compile-time module system that emits no import at all, so the layer annotation has nowhere to attach — and teams discover this after moving a design system to layers, when every partial’s rules land unlayered. The pattern that works is to open the layer in the emitted CSS instead. This extends build-pipeline layer automation in the architecture patterns reference.
Prerequisites
Dart Sass (the sass package), a partials directory structured by layer, and the manifest discipline that says layer order is declared in exactly one place.
src/styles/
_manifest.scss ← emits the @layer statement
_layer.scss ← the wrapper mixin
reset/_index.scss
base/_index.scss
theme/_index.scss
components/_index.scss
utilities/_index.scss
index.scss ← generated entryStep 1: Emit the manifest as literal CSS
// src/styles/_manifest.scss
// WHY a plain CSS at-rule rather than a Sass construct: the statement must
// appear verbatim, first, in the compiled output. Sass passes unknown
// at-rules through unchanged, which is exactly what is needed here.
@layer reset, vendor, base, theme, components, utilities, overrides;What this does: guarantees the order is registered before any rule. Because Sass emits this file’s content at the position it is forwarded from, keeping it as the first line of the entry file is what makes it first in the output.
Step 2: Wrap partials with a layer mixin
// src/styles/_layer.scss
// WHY a mixin rather than writing @layer by hand in every file: the name
// then comes from one place, and a rename is a single edit rather than a
// find-and-replace that misses two files.
@mixin in($name) {
@layer #{$name} {
@content;
}
}// src/styles/components/_card.scss
@use "../layer";
// WHY the wrapper here rather than in the entry file: a partial that opens
// its own layer is self-describing — you can read one file and know where
// its rules land, which matters when the entry file is generated.
@include layer.in(components) {
.card {
padding: var(--space-6);
border-radius: var(--radius-lg);
// Sass nesting works normally inside the layer block.
&__title { font-size: var(--font-size-md); }
&:hover { box-shadow: var(--shadow-md); }
}
}What this does: puts every partial’s rules inside the right layer in the compiled output, which is the closest equivalent to the layer() annotation Sass does not have.
Step 3: Generate the entry file from the directory tree
// scripts/generate-entry.mjs
// WHY generate: a hand-written entry file and a hand-written manifest are two
// lists that must agree, and they stop agreeing quietly. Deriving the entry
// from the manifest means only one can be wrong.
import { readdir, writeFile } from 'node:fs/promises';
const LAYERS = ['reset', 'vendor', 'base', 'theme', 'components',
'utilities', 'overrides'];
const lines = ['// GENERATED by scripts/generate-entry.mjs — do not edit',
'@use "manifest";', ''];
for (const layer of LAYERS) {
let files;
try {
files = (await readdir(`src/styles/${layer}`))
.filter((f) => f.startsWith('_') && f.endsWith('.scss'))
.sort(); // WHY sorted: deterministic source order
} catch {
continue; // a declared but unused layer is fine
}
if (!files.length) continue;
lines.push(`// ── ${layer} ──`);
for (const f of files) {
lines.push(`@use "${layer}/${f.replace(/^_|\.scss$/g, '')}";`);
}
lines.push('');
}
await writeFile('src/styles/index.scss', lines.join('\n'));What this does: removes the drift between “which layers exist” and “which files are compiled”. Run it in the build, and commit the output so the diff is reviewable.
Step 4: Respect Sass’s @use placement rule
// WRONG — Sass requires @use before any rule, and the mixin call is a rule.
// @include layer.in(components) { … }
// @use "../tokens"; ← compile error
// RIGHT — every @use first, then the wrapped content.
@use "../layer";
@use "../tokens";
@include layer.in(components) {
.card { color: tokens.$text; }
}What this does: avoids the most common compile error in this pattern. It also explains why the layer wrapper cannot enclose the whole file including its imports — the wrapper starts after them.
Verification
- Confirm the compiled CSS starts with the manifest:
head -1 dist/index.css # → @layer reset, vendor, base, theme, components, utilities, overrides;- Confirm no rule escaped its wrapper. This is the check that catches a partial someone added without the mixin:
# Count top-level rules that are NOT inside an @layer block.
npx postcss dist/index.css --no-map -o /dev/null --use postcss-reporter 2>/dev/null
node -e "
const postcss=require('postcss'),fs=require('fs');
const root=postcss.parse(fs.readFileSync('dist/index.css','utf8'));
let stray=0; root.walkRules(r=>{
let inLayer=false;
for(let n=r.parent;n;n=n.parent) if(n.type==='atrule'&&n.name==='layer') inLayer=true;
if(!inLayer) stray++;
});
console.log(stray+' unlayered rule(s)'); process.exit(stray?1:0);"- Regenerate the entry file and confirm
git diffis empty — a non-empty diff means someone edited it by hand or added a partial without rerunning the generator. - Load the page and read the registered layer order from the CSSOM to confirm it matches the manifest.
Troubleshooting
Compile error: “@use rules must be written before any other rules”.
: A @use appears after the layer mixin call. Move every @use to the top of the file; the wrapper starts below them.
Rules from one partial are unlayered in the output.
: That partial is missing its @include layer.in(...) wrapper. The compiler cannot detect this, which is why the CI check in Verification step 2 matters.
The manifest is not the first line.
: Something is forwarded before it. @use "manifest" must be the first statement in the generated entry, and no other partial may emit CSS at import time.
A layer name is misspelled and rules vanished.
: They did not vanish — they went into a newly created layer at the top of the stack, where they now outrank everything. Add csstools/no-unknown-layers to a Stylelint pass over the compiled output.
Nested layers produce components.components.
: A partial wrapped in layer.in(components) is being included from another file that already opened components. Wrap at exactly one level; the mixin is not idempotent.
Complete working example
// src/styles/_layer.scss
// WHY one mixin: the layer name is written in one place per partial and can
// be renamed without touching the CSS body.
@mixin in($name) {
@layer #{$name} {
@content;
}
}// src/styles/_manifest.scss
// WHY literal CSS: Sass passes unknown at-rules through verbatim, so this
// lands unchanged at the top of the compiled output.
@layer reset, vendor, base, theme, components, utilities, overrides;// src/styles/index.scss — GENERATED. Do not edit.
// WHY the manifest first: it fixes every layer's position before any rule is
// parsed, so no partial can define the order by accident of load sequence.
@use "manifest";
// ── reset ──
@use "reset/normalize";
@use "reset/controls";
// ── base ──
@use "base/typography";
@use "base/elements";
// ── theme ──
@use "theme/tokens";
@use "theme/dark";
// ── components ──
@use "components/button";
@use "components/card";
@use "components/field";
// ── utilities ──
@use "utilities/spacing";
@use "utilities/display";// src/styles/components/_card.scss
// WHY every @use first: Sass requires it, and the mixin call below is a rule.
@use "../layer";
@use "../theme/tokens";
@include layer.in(components) {
.card {
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
// WHY nesting is safe here: Sass treats @layer as an ordinary at-rule,
// so the compiled output is identical to hand-written layered CSS.
&__title {
font-size: var(--font-size-md);
font-weight: 600;
}
&:hover { box-shadow: var(--shadow-md); }
// Media queries nest inside the layer without creating a new one.
@media (min-width: 48rem) {
padding: var(--space-8);
}
}
}{
"scripts": {
"//": "WHY generate before compile: the entry file is an artefact of the directory tree, and building from a stale one is how a new partial silently ships unlayered.",
"css:generate": "node scripts/generate-entry.mjs",
"css:build": "npm run css:generate && sass src/styles/index.scss dist/index.css --no-source-map",
"css:verify": "node scripts/verify-layers.mjs dist/index.css"
}
}Frequently Asked Questions
Can Sass @use assign a file to a cascade layer?
No, and it is not an oversight. The layer() annotation is part of the CSS @import rule, which survives to runtime for the browser to act on. @use is a compile-time module system: it resolves the dependency, emits the partial’s CSS inline, and produces no import for an annotation to attach to. The layer therefore has to be opened inside the emitted CSS, which is what the wrapper mixin does.
Does Sass nesting work inside an @layer block?
It does. Sass treats @layer as an ordinary unknown at-rule, so selectors, & references, media queries and nested rules inside the block all compile exactly as they would outside it. The compiled output is byte-for-byte what you would write by hand, which means nothing about your existing partials needs restructuring beyond adding the wrapper.
Should the manifest be generated or hand-written?
The manifest can be hand-written — it is one short line and it encodes a real architectural decision. What should be generated is the entry file, from the same layer list. Two hand-maintained lists that must agree will stop agreeing, and the failure is silent: a partial in a folder nobody added to the entry simply never compiles, and a layer named in a wrapper but absent from the manifest is created at the top of the stack.
Related
- Build-Pipeline Layer Automation — the wider automation strategy
- Automating Layer Order With PostCSS and Vite — the same guarantees without a preprocessor
- Splitting Layered CSS Into Critical and Deferred Bundles — what happens to this output downstream
- Enforcing a Layer Contract With Stylelint Rules — linting the compiled CSS for stray unlayered rules
Up: Build-Pipeline Layer Automation → Architecture Patterns & Design System Scaling