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-layersWhat 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.
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 -rnWhat 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.
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.jsonWhat this does: turns the contract into something a pull request cannot merge past.
Verification
- Create a scratch branch containing
@layer nonsense { .x { color: red } }and confirm CI fails withno-unknown-layers. - Add an unlayered rule to a component file and confirm
no-unlayered-rulesfires. - Confirm the manifest file itself still passes — if it fails, the override path is wrong.
- Check the bundle lint catches something the source lint does not:
# Any layer name in the built CSS that is not in the manifest.
grep -o "@layer [a-z0-9.\-]*" dist/assets/*.css | sort -uTroubleshooting
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.
Related
- Layer Naming Conventions and Governance — the contract these rules enforce
- Naming Cascade Layers for a Multi-Team Design System — the names the linter validates against
- Auditing the Overrides Layer in CI — the companion check for the escape-hatch layer
- Build-Pipeline Layer Automation — where the bundle lint fits in the pipeline
Up: Layer Naming & Governance → Architecture Patterns & Design System Scaling