Lints CSS @layer order across a whole project — the cross-file bugs no
single-file tool, including stylelint, checks for.
two stylesheets, loaded on the same checkout page:
design-system.css @layer reset, base, components;
checkout-page.css @layer components, base, reset, page-overrides;
Both look like ordinary, safe declarations. Whichever one the browser parses first wins,
silently, for every page that loads both files — and which one that is depends on <link>
order or bundler output order, not on anything visible in either file.
checkout-page.css:1:1 error layer-order-mismatch
layer "components" is declared before "base" (top level) in checkout-page.css, but "base"
is declared before "components" in design-system.css. Layer order is set by each layer's
first occurrence across the whole document (CSS Cascade 5 §6.4.3), so which one wins
depends entirely on which stylesheet the browser loads first.
That's node examples/order-mismatch-demo.ts (paths shortened here; the real
output has full absolute paths). Neither file is wrong on its own — the bug
only exists in the relationship between them.
@layer is now supported everywhere that matters, and it changes how the
cascade resolves in ways that are easy to get backwards and hard to debug,
because the bug is invisible in any single file:
- Declared order mismatch across files.
@layer reset, base, components;in one file and@layer components, base, reset;in another means the winning layer depends on which file the browser happens to parse first —<link>order in the HTML, or bundler output order, neither of which is visible from either stylesheet. - Duplicate layer names silently merge. Two teams both write
@layer utilities { ... }, each assuming they own that name. Per spec, identical layer names are the same layer — their rules merge, in first-occurrence order, whether anyone intended that or not. - Unlayered styles beat every layer, unconditionally. This is backwards
from most people's intuition: a plain, unlayered
.btn { }always wins over@layer components { .btn { } }, regardless of specificity, source order, or which file loads first. A "temporary override" left outside a@layersilently becomes permanent and un-overridable from inside any layer. @layer a.bwithanever declared on its own, so the parent's position in the global order is decided purely by wherever the dotted shorthand happens to be first seen.@import url(...) layer(name)withnamenever given an explicit order, same problem from the import side.- An
@layerstatement whose claimed order is already a lie, because the layers it lists already got their real order from earlier rules in the same file — or, worse, a bare@layerstatement placed after an@import, which per spec causes every subsequent@importin the file to be silently ignored by the browser.
stylelint parses @layer syntax fine — it has to, to not choke on valid
CSS — but nothing in its rule set, or in the wider plugin ecosystem, checks
whether the layer order declared across files is even consistent. The
closest published plugin, stylelint-cascade-layers,
has exactly one rule, require-layers, which enforces that @layer is used
at all — it has no opinion on order, duplicate names, or unlayered
collisions. Nothing else turned up in a search of the stylelint plugin
ecosystem for order-aware, cross-file @layer linting. That's the gap this
fills.
Every bug above requires holding two files in your head at once and mentally merging their layer order — and the merge rule itself is unintuitive (first occurrence wins, unlayered beats every layer, identical names merge). A single-file linter, or a human reviewing one pull request that only touches one file, cannot see a contradiction that only exists between files. That's exactly the case this tool exists for.
npm install --save-dev css-layer-lintNode >= 20.6. No runtime dependencies — the CSS parsing is hand-rolled (see What it does not do for the deliberate limits of that).
npx css-layer-lint "src/**/*.css"design-system.css:1:1 error layer-order-mismatch
layer "components" is declared before "base" (top level) in design-system.css, but "base"
is declared before "components" in checkout-page.css. ...
2 file(s) analysed — 1 error(s), 0 warning(s), 0 info(s)
Resolvable @imports are followed automatically, even to files outside your
glob, because the interesting bugs are cross-file. Pass more than one glob to
cover several source directories:
npx css-layer-lint "src/**/*.css" "packages/*/styles/*.css"Or use it as a library:
import { lintProject } from 'css-layer-lint';
const result = await lintProject(['src/**/*.css']);
if (result.hasErrors) {
for (const f of result.findings) console.error(f.rule, f.message);
process.exitCode = 1;
}| Option | What it does |
|---|---|
--json |
Print the full LintResult as JSON instead of text. |
--cwd <dir> |
Resolve globs relative to <dir> (default: current directory). |
--no-follow-imports |
Analyse only the files the globs match; don't follow @import. |
--quiet |
Only print error-severity findings. |
-h, --help |
Print usage. |
Exit codes: 0 — no error-severity findings. 1 — at least one
error-severity finding, or a file could not be parsed. 2 — usage error
(bad flags, or no glob patterns given).
| Rule | Severity | Catches |
|---|---|---|
layer-order-mismatch |
error* | Two files (or two points in one file) disagree about the relative order of the same two layers. |
unlayered-collision |
error | The same selector is styled once unlayered and once inside a layer — the unlayered one always wins. |
layer-after-import |
error | A bare @layer rule follows an @import, silently voiding every later @import in that file. |
duplicate-layer-name |
warning | The same fully-qualified layer name is populated with rules in more than one file. |
misleading-order-statement |
warning | An @layer a, b; statement claims an order that earlier rules in the same file already contradicted. |
unresolved-nested-parent |
warning | @layer a.b is used but a is never declared on its own anywhere in the project. |
unordered-import-layer |
warning | @import ... layer(name) where name never appears in an explicit order statement. |
parse-error |
error | A construct the parser can't confidently understand — see below. |
* layer-order-mismatch is downgraded to a warning when either
occurrence is inside a conditional rule (@media/@supports/@container):
per the spec, layer order established inside a conditional can legitimately
vary by which condition matches at runtime, so a "mismatch" there might be
intentional (see the note in §6.4.3).
This tool's whole value depends on getting layer precedence right, so every rule cites CSS Cascade Level 5 §6.4 directly rather than folk wisdom:
- Unlayered beats every layer. §6.4.3: "any unlayered style rules
[are] added to an implicit outer layer which has higher priority than
(comes after) the explicit layers." This is the opposite of most
developers' first guess, and getting it backwards would make this tool
actively harmful — so
unlayered-collisionis tested against exactly this direction (seetest/fixtures/unlayered-collision). - Later-declared layers win. §6.4.3: "Cascade layers are sorted by the
order in which they first are declared." First occurrence sets position;
later occurrences of the same name don't move it. This is also why
misleading-order-statementexists — a statement written after the fact can claim an order it no longer has the power to set. - Identical names are the same layer. §6.4.2: "Layer names represent
the same cascade layer if they contain the same segments in the same
order." This is what makes
duplicate-layer-namea real (if sometimes intentional) risk rather than a false alarm. @layerafter@importbreaks later imports. §6.4.4.2: "Any@layerrule that comes after an@import... rule will cause any subsequent@import... rules to be ignored." Verified by fetching and reading the spec text directly (§6.4.4.2, statement-form@layer).
- No specificity or full selector-equivalence reasoning.
unlayered-collisionmatches selectors by normalized text equality..btnand.btn.activeare different selectors to this tool even though they can collide in practice — that requires a full selector-specificity engine, which is out of scope for a zero-dependency linter. - Doesn't evaluate
@media/@supports/@containerconditions. It parses into them (so it can see@layerand unlayered rules declared there), but never decides which branch is "true." Order established only inside a conditional is reported as a lower-confidence warning, not silently treated as unconditional fact — see the spec's own media-query-dependent-order example in §6.4.3. - Doesn't resolve bundler aliases,
node_moduleslookups, or CSS-in-JS.@import "tailwindcss"or@import "~ui/button.css"are reported as unresolved (bare-specifier) rather than guessed at. Only specifiers that resolve to a real file relative to the importing stylesheet are followed — the same rule a browser uses for a plain CSS@import. - Doesn't fetch remote
@import url("https://..."). Reported as unresolved (remote), never fetched. - Doesn't understand CSS nesting selectors (
&) semantically beyond treating them as opaque selector text — nesting parses fine structurally, but&.activeisn't resolved against its parent selector for collision matching. - Refuses on at-rule blocks it doesn't recognize, rather than guessing.
@media,@supports,@container,@scope,@layer,@import,@keyframes,@font-face,@page,@property,@counter-style,@font-feature-values,@font-palette-values,@viewport, and Tailwind v4's@themeare understood. An unrecognized block at-rule is a loudParseErrornaming the construct — the file is excluded from cross-file analysis and the tool exits non-zero, rather than quietly skipping the part it didn't understand and reporting a clean bill of health it didn't earn.
test/fixtures/ has one small multi-file project reproducing each bug above,
plus clean-project/ — a consistent four-file project (with a nested
@media block and a @keyframes rule) that must, and does, produce zero
findings.
It was also run, read-only, against a real production codebase — a Next.js +
Tailwind v4 application with a hand-built design-token system, around ten CSS
files split across separate route bundles, using @theme, @custom-variant,
@plugin and nested @media. Real result: 0 errors, 1 warning — a layer
named base was populated in two stylesheets that a comment indicated are
loaded by different bundles, so they likely never ship on the same page.
That is an honest result rather than a cherry-picked one. The finding is a true structural fact (both files do populate a layer of the same name), and it is correctly a warning rather than an error, because this tool cannot see a framework's bundling topology and so cannot know whether the two files ever meet. Resolving it needs a human. Nothing else fired: no order mismatches, no unlayered collisions, no stray dotted layers. One explainable signal across ten real files is the ratio this tool is built to produce.
Tests are TypeScript run directly by Node's test runner — no build, no install:
node --test "test/*.test.ts" # full suite, needs node 24+ for type stripping
node examples/order-mismatch-demo.ts
npm run build && npm run test:dist # what CI runs against node 20 and 22MIT