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:

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.

Cascade resolution order Four numbered boxes stacked vertically showing the browser's sort order: 1 Origin and Importance, 2 Layer Index, 3 Specificity, 4 Source Order. An arrow labelled "browser evaluates top to bottom — first decisive stage wins" points downward alongside them. 1 Origin & Importance user-agent → user → author; !important sub-tiers within each origin 2 Layer Index ← this page later-declared layer wins; unlayered = implicit terminal layer (always highest) 3 Specificity (a,b,c) tuple — only compared inside the same layer 4 Source Order last declaration wins within the same layer and specificity first decisive stage wins

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):

  1. Select the element in the Elements panel.
  2. In the Styles tab, locate the property. Struck-through declarations lost the cascade.
  3. Each rule shows a @layer badge with the layer name. Confirm the winning rule’s badge corresponds to the higher-index layer.
  4. 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.