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 entry

Step 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.

Why the CSS layer() annotation has no Sass equivalent Two paths. A CSS import with a layer annotation produces a runtime import whose rules land in the named layer. A Sass use rule is resolved at compile time and emits no import, so its rules appear unlayered unless the partial itself opens a layer block. Plain CSS @import url("card.css") layer(components); the annotation rides on the import @layer components { .card { … } } browser places the rules for you The import survives to runtime, so the annotation has something to attach to. Sass @use "card"; ← no layer() form exists resolved at compile time .card { … } unlayered — outranks every layer No import reaches the browser, so the layer must be opened INSIDE the partial's emitted CSS.

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.

The Sass-to-layered-CSS pipeline Partials organised per layer feed a generated entry file, which the Sass compiler turns into CSS. A verification step then checks two properties of the output: the manifest appears first, and no rule sits outside a layer. partials one folder per layer layer.in(name) generated entry index.scss manifest first sass compiler at-rules pass through nesting preserved verified CSS manifest is line 1 zero unlayered rules The two checks on the right are the whole safety net: Sass will happily compile a partial that forgot its wrapper, and the resulting unlayered rules then outrank every layer in the bundle. Neither failure produces a compiler warning, which is why the verification runs in CI.

Verification

  1. Confirm the compiled CSS starts with the manifest:
The two checks that make the Sass pipeline safe Two verification gates on the compiled CSS. The first confirms the layer manifest is the opening line of the output. The second counts rules that are not enclosed by a layer at-rule and fails when the count is above zero. check 1 — manifest first head -1 dist/index.css @layer reset, vendor, base, theme, components, utilities; catches a partial that emits CSS before the entry's first @use check 2 — nothing stray count rules outside @layer 0 unlayered rule(s) catches a partial that forgot its layer.in() wrapper — Sass will not
head -1 dist/index.css   # → @layer reset, vendor, base, theme, components, utilities, overrides;
  1. 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);"
  1. Regenerate the entry file and confirm git diff is empty — a non-empty diff means someone edited it by hand or added a partial without rerunning the generator.
  2. 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.

Up: Build-Pipeline Layer AutomationArchitecture Patterns & Design System Scaling