Enforcing a Layer Contract With Stylelint Rules

The browser will never tell you that someone invented a layer. @layer checkout-fixes { … } with no prior declaration is legal CSS: the name is registered at the top of the stack, the rules win against everything, and the page renders. The only place that mistake is catchable is static analysis, which makes a lint configuration a structural part of layer naming conventions and governance rather than a code-style preference. This page is part of the architecture patterns reference.

Prerequisites

You need a manifest file — one place where every permitted layer is declared in order — because the lint rules are checked against it. If you do not have one yet, create it first; the rules below have nothing to validate otherwise.

/* src/styles/layers.css — the single source of truth the linter reads. */
@layer reset, vendor, base, theme, components, utilities, overrides;

Tooling: Node 18+, Stylelint 16+, and @csstools/stylelint-plugin-cascade-layers.

Step 1: Install the plugin

npm install --save-dev stylelint @csstools/stylelint-plugin-cascade-layers

What this does: adds the two rules that understand layer semantics. Stylelint’s built-in rules know nothing about @layer ordering, so the plugin is doing the real work here.

Where layer enforcement runs, and which point is binding Three sequential enforcement points. The editor gives instant feedback but can be ignored. The pre-commit hook is faster but skippable with a flag. The CI job on the pull request is the only gate that cannot be bypassed, and it lints both sources and the emitted bundle. editor plugin instant feedback advisory only pre-commit hook staged files only skippable: --no-verify CI on the pull request ALL files, not just changed sources AND emitted bundle the binding gate A manifest edit can break a file nobody touched — which is why the CI run must lint everything, and why a changed-files-only pre-commit hook is a convenience rather than an enforcement point. Bundle linting is the half that catches framework and CSS-in-JS output no source file contains.

Step 2: Reject unknown layer names

{
  "plugins": ["@csstools/stylelint-plugin-cascade-layers"],
  "rules": {
    "csstools/no-unknown-layers": true
  }
}

What this does: fails on any @layer name that has not been declared before use. This is the single highest-value rule in the configuration — it converts the silent, invisible failure described above into a build error with a file and a line number.

Step 3: Require every rule to live in a layer

{
  "rules": {
    "csstools/no-unlayered-rules": true
  },
  "overrides": [
    {
      "files": ["src/styles/layers.css"],
      "rules": { "csstools/no-unlayered-rules": null }
    },
    {
      "files": ["src/styles/vendor/**/*.css"],
      "rules": { "csstools/no-unlayered-rules": null }
    }
  ]
}

What this does: catches the other half of the problem. Unlayered author styles outrank every layer, so a rule written outside a layer quietly defeats the whole architecture. The overrides are necessary rather than cosmetic: the manifest contains no rules at all, and vendored files are wrapped at import time rather than in their own source.

Step 4: Constrain names and importance

{
  "rules": {
    "csstools/cascade-layer-name-pattern": ["^[a-z][a-z0-9-]*$"],
    "declaration-no-important": true
  },
  "overrides": [
    {
      "files": ["src/styles/utilities/**/*.css", "src/styles/reset/**/*.css"],
      "rules": { "declaration-no-important": null }
    }
  ]
}

What this does: the name pattern keeps the manifest readable and prevents @layer Checkout_Fixes variants from multiplying. The importance rule is the interesting one: with a working layer order, almost no !important is justified, and the two exceptions are utilities that must win by design and the reset layer where an important rollback cancels vendor CSS. Confining it to those paths turns “we should use less !important” into an enforced boundary.

Step 5: Adopt in warning mode, then promote

# 1. See the damage without blocking anyone.
npx stylelint "src/**/*.css" --quiet-deprecation-warnings

# 2. Fix by category, not by file — usually one root cause per rule.
npx stylelint "src/**/*.css" --custom-formatter=json | jq -r \
  '.[].warnings[].rule' | sort | uniq -c | sort -rn

What this does: produces a ranked list of which rule is failing most, which is almost always a single systemic cause — one team’s directory written before the contract existed, or one build plugin emitting unlayered output.

Which rule catches which failure Four rows pairing a Stylelint rule with the specific production failure it prevents: no-unknown-layers stops a silently created top-level layer, no-unlayered-rules stops a rule that outranks every layer, the name pattern stops manifest drift, and declaration-no-important stops importance leaking outside utilities and reset. rule failure it prevents csstools/no-unknown-layers a new layer appended above the design system csstools/no-unlayered-rules a rule that outranks every layer by being outside them cascade-layer-name-pattern two spellings of one layer, both registered declaration-no-important importance inverting your layer order by accident

Step 6: Make it binding in CI

# .github/workflows/css.yml
name: CSS contract
on: [pull_request]
jobs:
  stylelint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      # WHY: every file, not just changed ones — a manifest edit can break a
      # file this pull request never touched.
      - run: npx stylelint "src/**/*.css" --max-warnings 0
      # WHY: lint the OUTPUT too. Plugins and CSS-in-JS runtimes emit CSS that
      # exists in no source file, and that is where stray layers come from.
      - run: npm run build
      - run: npx stylelint "dist/**/*.css" --max-warnings 0 --config .stylelintrc.bundle.json

What this does: turns the contract into something a pull request cannot merge past.

Verification

  1. Create a scratch branch containing @layer nonsense { .x { color: red } } and confirm CI fails with no-unknown-layers.
  2. Add an unlayered rule to a component file and confirm no-unlayered-rules fires.
  3. Confirm the manifest file itself still passes — if it fails, the override path is wrong.
  4. Check the bundle lint catches something the source lint does not:
Adopting the rules without disabling them A falling warning count across four sprints as directories are brought into compliance, with a marker at the point where the rule is promoted from warning to error once the count reaches zero. lint warnings, by sprint S1 S2 S3 S4 S5 412 0 → error Fix by rule category, not by file — each spike is usually one systemic cause, not many small ones.
# Any layer name in the built CSS that is not in the manifest.
grep -o "@layer [a-z0-9.\-]*" dist/assets/*.css | sort -u

Troubleshooting

no-unknown-layers fires on a legitimate nested layer. : The parent was declared but the child was not. Add the child to the manifest’s nested list — @layer components.checkout — rather than disabling the rule for that file.

The rule cannot see the manifest. : Stylelint analyses each file independently unless the manifest is imported into it. Either lint the concatenated bundle, or keep a @layer statement at the top of each entry file so the declaration is visible in the same parse.

CI passes but production has an extra layer. : A runtime injects styles that no source file contains. Bundle linting catches build-time emission; runtime injection needs the CSSOM assertion described in testing cascade layers across browsers.

Half the codebase fails on day one. : Expected. Run in warning mode, fix by rule rather than by file, and promote to error per directory as each one comes clean. A big-bang switch to error mode gets the rule disabled instead of the code fixed.

A vendored file legitimately has unlayered rules. : Vendored CSS is wrapped at import time, so its source is not expected to contain layers. Add the vendor path to the overrides, and keep the wrapping assertion in the bundle lint where it belongs.

Complete working example

{
  "plugins": ["@csstools/stylelint-plugin-cascade-layers"],
  "rules": {
    "csstools/no-unknown-layers": true,
    "csstools/no-unlayered-rules": true,
    "csstools/cascade-layer-name-pattern": ["^[a-z][a-z0-9-]*$"],
    "declaration-no-important": true,
    "no-duplicate-selectors": true
  },
  "overrides": [
    {
      "comment": "The manifest declares layers and contains no rules.",
      "files": ["src/styles/layers.css"],
      "rules": { "csstools/no-unlayered-rules": null }
    },
    {
      "comment": "Vendored CSS is wrapped by @import layer(), not at source.",
      "files": ["src/styles/vendor/**/*.css"],
      "rules": {
        "csstools/no-unlayered-rules": null,
        "declaration-no-important": null
      }
    },
    {
      "comment": "Utilities win by design; reset carries important rollbacks.",
      "files": ["src/styles/utilities/**/*.css", "src/styles/reset/**/*.css"],
      "rules": { "declaration-no-important": null }
    },
    {
      "comment": "Legacy is frozen — lint it, but do not block the migration on it.",
      "files": ["src/styles/legacy/**/*.css"],
      "rules": {
        "csstools/no-unlayered-rules": [true, { "severity": "warning" }],
        "declaration-no-important": [true, { "severity": "warning" }]
      }
    }
  ]
}

Frequently Asked Questions

Why can't the browser catch an undeclared cascade layer?

Because creating a layer on first use is specified behaviour rather than an error. The engine registers the unfamiliar name at the end of the current stack and continues parsing, exactly as the spec requires, so there is no console message and no runtime signal of any kind. The consequence — a rule that now outranks the entire design system — is invisible until someone notices a component rendering wrongly on one route.

How do you lint CSS that is generated at build time?

Run a second Stylelint pass over the emitted bundle with a slimmer configuration. Source linting covers what engineers write; bundle linting covers what PostCSS plugins, component frameworks and CSS-in-JS runtimes produce, and that output is where implicitly created layers most often originate. The two passes catch genuinely different classes of defect, so neither replaces the other.

Should the lint run on every file or only on changed files?

Every file, at least in CI. Layer contracts break through interaction: editing the manifest can invalidate a file that this change never touched, and a changed-files-only run would report green. Keep the fast changed-files pass in the pre-commit hook for feedback, and make the full run the gate that decides whether a pull request merges.

Up: Layer Naming & GovernanceArchitecture Patterns & Design System Scaling