CSS Specificity Across Multiple @layer Blocks
An ID selector in @layer base loses to a plain class in @layer components — understanding why requires a two-step calculation that puts layer index ahead of selector weight, as covered in Calculating Selector Weight in Layers and the broader Specificity Management & Conflict Resolution framework.
Prerequisites
You should be familiar with:
- How
@layerdeclaration order establishes priority — layer index, not selector weight, is the primary sort key - Standard
(a,b,c)specificity tuple arithmetic (IDs, classes, elements) - Basic CSS cascade fundamentals and layer syntax
No build tools are required. A browser DevTools panel (Chrome 99+, Firefox 97+, Safari 15.4+) is enough to verify every step.
The cascade resolution diagram
The following diagram shows where layer index fits inside the full cascade sort order. Specificity only enters once the browser has already narrowed candidates to the same layer.
Step-by-step procedure
Step 1 — Read the layer declaration order
The @layer statement at the top of your stylesheet defines the priority sequence. Later position = higher priority.
/* WHY: declaring the sequence up-front locks layer priority regardless
of where the actual rule blocks appear later in the file or in imports */
@layer reset, base, theme, components, utilities;
/* Layer index:
reset → 0 (lowest priority among named layers)
base → 1
theme → 2
components → 3
utilities → 4 (highest priority among named layers)
(unlayered)→ ∞ (implicit terminal — always wins) */What this does: Every @layer block you write later is assigned one of these indices. The browser resolves competing declarations by comparing index numbers, not by reading selector weight at all.
Step 2 — Classify every competing declaration as layered or unlayered
Scan the stylesheet and mark whether each rule is inside a @layer block.
@layer components {
/* layered — index 3 */
.card { background: white; }
}
/* unlayered — terminal layer, always wins over ALL named layers */
.card { background: black; }What this does: Unlayered declarations are never “just sitting there” — the browser silently treats them as a single implicit layer above all named layers. Even a universal selector * written outside any @layer block will override an ID selector inside one.
Step 3 — Assign a layer index to each competing rule
Map every candidate declaration to its layer position from Step 1.
@layer base {
/* Specificity: (1,1,0) — but layer index is 1 */
#app-container .card {
border-color: #ccc;
}
}
@layer components {
/* Specificity: (0,1,0) — but layer index is 3 */
.card {
border-color: blue;
}
}| Rule | Layer | Index | Specificity |
|---|---|---|---|
#app-container .card |
base |
1 | (1,1,0) |
.card |
components |
3 | (0,1,0) |
What this does: Writing this table out makes the outcome obvious before running a browser: index 3 > index 1, so components wins regardless of the ID selector in base.
Step 4 — Compare layer indices; skip specificity for cross-layer rules
If the competing declarations are in different layers, the one with the higher index wins. You are done — do not compare specificity tuples across layers.
/* Result: .card border-color is blue
WHY: components (index 3) > base (index 1),
even though #app-container .card has higher specificity */Comparing specificity across layers is architecturally invalid. The (1,1,0) in base is never placed on the same scale as the (0,1,0) in components.
Step 5 — Apply specificity only for intra-layer conflicts
When two rules share the same layer, fall back to standard (a,b,c) tuple comparison.
@layer components {
/* (1,1,0) — wins within this layer */
#main .card { background: #e6f0ff; }
/* (0,2,0) — loses to the rule above, intra-layer */
.layout .card { background: white; }
}What this does: Within a single layer the cascade behaves exactly as it did before @layer existed — specificity is the tiebreaker, with source order as the final fallback.
Reference table for common selector types:
| Selector | Tuple | Notes |
|---|---|---|
#id |
(1,0,0) |
— |
.class, [attr], :hover |
(0,1,0) |
each contributes one b unit |
div, ::before |
(0,0,1) |
— |
*, combinators |
(0,0,0) |
no weight |
:where(.btn) |
(0,0,0) |
always zero, by spec |
:is(.btn, #main) |
(1,0,0) |
takes most-specific argument |
:not(.active) |
(0,1,0) |
same rule as :is() |
Step 6 — Handle !important inversion
When !important is involved, the layer priority order reverses for that sub-tier. The earlier-declared layer’s !important beats a later layer’s !important. This is the opposite of normal behaviour and is a common source of specificity leaks when debugging layered stylesheets.
@layer reset, base, theme, components, utilities;
@layer reset {
/* !important in reset (index 0) beats !important in components (index 3)
WHY: !important reverses the layer stack — lowest index now wins
This lets resets enforce truly non-negotiable browser defaults */
* { box-sizing: border-box !important; }
}
@layer components {
/* This !important LOSES to reset's !important above,
even though components has a higher layer index */
.card { box-sizing: content-box !important; }
}You rarely need !important inside @layer blocks. For the authoritative treatment of this inversion, see The Role of !important in Layers.
Verification
Open DevTools and confirm the computed value matches your prediction.
DevTools trace (Chrome / Edge):
- Select the element in the Elements panel.
- In the Styles tab, locate the property. Struck-through declarations lost the cascade.
- Each rule shows a
@layerbadge with the layer name. Confirm the winning rule’s badge corresponds to the higher-index layer. - In Chrome 108+ use the Layers sidebar in Styles to see all layers ranked in priority order.
Console assertion (paste in DevTools Console):
/* WHY: getComputedStyle gives the final resolved value after cascade —
compare it against what your layer analysis predicted */
const el = document.querySelector('.card');
const actual = getComputedStyle(el).borderColor;
console.assert(
actual === 'rgb(0, 85, 255)',
`Expected blue from components layer, got: ${actual}`
);Stylelint enforcement:
{
"plugins": ["@csstools/stylelint-plugin-cascade-layers"],
"rules": {
"csstools/cascade-layers": true
}
}Run npx stylelint "**/*.css" in CI to catch rules placed outside their intended layer before they reach production.
Troubleshooting
Unlayered vendor styles winning unexpectedly
: A third-party stylesheet injects rules outside any @layer block. Those rules become part of the implicit terminal layer and override all your named layers. Fix: wrap the import using @import url("vendor.css") layer(vendor); — this confines vendor styles to a named layer you control. See Resolving Third-Party CSS Conflicts for the full pattern.
Build tool concatenation inverts layer order
: If your bundler merges files alphabetically or by entry-point order, the physical @layer blocks may appear in a different order than your @layer reset, base, … declaration. Audit with grep -n "@layer" dist/styles.css to confirm the declaration statement appears before any block rule. Misaligned imports are the most common production bug in layered architectures.
!important in a utility layer losing to reset
: Because !important reverses layer priority, a !important in @layer utilities (high index) loses to !important in @layer reset (low index). Remove !important from named layers wherever possible; reserve it for truly non-negotiable cross-cutting rules placed in the lowest-index layer.
Specificity appears to bleed across layers
: This is always an unlayered rule masquerading as part of a named layer. Check whether the rule is actually inside the @layer block (braces closed correctly) or written at the top level of the file. Linters and the DevTools @layer badge will show unlayered rules without a badge.
:is() / :has() raising effective specificity unexpectedly
: :is(.btn, #primary) takes the specificity of its most specific argument — #primary — giving (1,0,0) even when the matched element only has a class. Within the same layer this can override rules you expected to win. Switch the high-specificity argument to :where(#primary) to zero it out: :is(.btn):where(#primary) keeps the match but contributes (0,1,0).
Complete working example
Self-contained example demonstrating all six steps in one stylesheet. Copy-paste into a browser’s DevTools Styles override or a local HTML file to observe the results.
/* ============================================================
COMPLETE SPECIFICITY-ACROSS-LAYERS EXAMPLE
Expected result for .card:
background : #e6f0ff (from @layer overrides)
border : 3px solid #003399 (from @layer overrides)
color : #ffffff (from the unlayered rule — terminal layer wins)
============================================================ */
/* WHY: declare sequence before any block rules so layer index is
unambiguous regardless of file concatenation order */
@layer reset, base, theme, components, overrides;
@layer reset {
*, *::before, *::after {
box-sizing: border-box; /* WHY: isolate sizing model from inherited defaults */
margin: 0;
padding: 0;
}
}
@layer base {
/* Specificity (1,1,0) — but base has layer index 1 (second-lowest) */
#app-container .card {
background: #f0f0f0; /* WHY: neutral base surface, intentionally easy to override */
border: 1px solid #ccc;
}
}
@layer theme {
:root {
--color-primary: #0055ff; /* WHY: single source of truth for brand colour */
--color-surface: #ffffff;
}
}
@layer components {
/* Specificity (0,1,0) — lower than base's ID rule, but index 3 beats index 1 */
.card {
background: var(--color-surface);
border: 2px solid var(--color-primary);
/* WHY: component layer owns structural appearance;
overrides (index 4) can still customise per-context */
}
}
@layer overrides {
/* Specificity (0,1,0) — same as components, but index 4 > index 3 */
.card {
background: #e6f0ff; /* WHY: context-specific surface colour for this view */
border: 3px solid #003399;
}
}
/* UNLAYERED — implicit terminal layer, wins over all @layer blocks */
/* WHY: demonstrate the terminal-layer effect; in production, avoid this */
.card {
color: #ffffff; /* This wins even though it has (0,1,0) specificity */
}Frequently asked questions
Does an ID selector in an earlier @layer beat a class selector in a later one?
No. Layer index is evaluated before specificity. An ID selector (1,0,0) in @layer base (index 1) always loses to a class selector (0,1,0) in @layer components (index 3). Specificity only enters the calculation when two rules share the exact same layer — at that point standard (a,b,c) tuple comparison applies.
Do unlayered styles always win, and can I stop that?
In the author origin: yes, by design. Any declaration written outside an @layer block is placed in a single implicit terminal layer with the highest author-origin priority. The only way to lower its position is to move it inside an @layer block. If you inherit a stylesheet you cannot edit, wrap the entire @import using the layer function syntax: @import url("legacy.css") layer(legacy); — this promotes the file’s rules into a named layer you control.
How do nested @layer blocks affect specificity?
Nested layers (e.g. @layer components.button) are flattened by the browser into a priority order that respects both the parent layer’s index and the sibling order within that parent. Specificity is still only compared within the innermost leaf layer. A rule in components.button never has its specificity compared against a rule in components.card — their relative priority is decided by which sub-layer was declared first inside @layer components. For the full nesting mechanics see Nested Layers and Inheritance.
Related
- Calculating Selector Weight in Layers — the parent cluster covering the full specificity model inside
@layer - Debugging Specificity Leaks — tracing the source of unexpected overrides in production builds
- The Role of
!importantin Layers — complete treatment of priority inversion - Resolving Third-Party CSS Conflicts — wrapping vendor styles to prevent unlayered terminal-layer takeovers
- Specificity Management & Conflict Resolution — root pillar