CSS Cascade Layers (@layer): End Specificity Wars in Large Codebases
Every large CSS codebase eventually arrives at the same standoff: a reset stylesheet, some base element styles, a
component library, a pile of utility classes and somebody else's third-party widget CSS — all fighting over the same
elements in one shared cascade. Nobody designs this conflict on purpose. It accumulates one !important and one extra
.wrapper at a time, until changing a single button's colour means searching the whole codebase to find out which of
six competing rules is actually winning.
Cascade layers — the @layer at-rule — are CSS's own fix for exactly this. They don't add a new kind of
specificity, and they don't replace the cascade. They add a second, explicit axis of priority that's checked before
specificity: you declare an order for named groups of rules, and a rule in a later group beats a rule in an earlier
group, however specific the earlier rule's selector is.
Asking "which layer?" before "which selector?" is the whole idea. It's also why this post spends real time on the
parts that surprise people once they start relying on it: unlayered styles have a priority of their own, !important
turns the layer order upside down, and a layer's position, once fixed, can't be moved by a later statement that looks
as if it should move it.
In this post, we'll cover:
- The specificity arms race
@layerexists to end - The basic syntax: declaring an order, then filling layers in
- Why unlayered styles always win, and how to use that during a migration
- Anonymous layers, nested layers and importing third-party CSS straight into a layer
- Why
!importantreverses layer priority, and howrevert-layerrolls a layer back - The complete cascade sort order, with
@layerin its proper place - An interactive playground where a real browser decides the winner
- Migrating a large codebase, and the "utilities last" pattern
- Debugging layers, browser support, common pitfalls and frequently asked questions
The Specificity Arms Race
Picture an ordinary mid-sized app. Somewhere in its stylesheets, in roughly this order, you'll find:
/* 1. A reset, probably vendored in */
*, *::before, *::after {
box-sizing: border-box;
}
button {
font: inherit;
}
/* 2. Base element defaults for the whole app */
body {
color: #1f2937;
font-family: system-ui, sans-serif;
}
/* 3. A component library's button */
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
background: #e5e7eb;
}
.btn--primary {
background: #2563eb;
color: white;
}
/* 4. A utility class, meant to be a quick one-off override */
.bg-slate-100 {
background: #f1f5f9;
}
/* 5. Somebody's "just this once" fix, six months later */
#app .card .btn.btn--primary.bg-slate-100 {
background: #f1f5f9 !important;
}None of this was written carelessly. Every rule was a reasonable decision on its own. The reset needs to run first.
The component library reasonably assumes its own class names are enough. The utility is deliberately a single class,
because utilities are meant to be small and composable. And rule 5 exists because someone needed .bg-slate-100 to
apply over .btn--primary, found that specificity wouldn't let it, and reached for the two tools that always "work":
a longer selector and !important.
That's the arms race. Specificity is a measurement of how a selector is written — it knows nothing about what kind
of rule it is or where that rule sits in your architecture. A one-class utility can never outrank a compound component
selector on specificity alone, so as soon as a team wants utilities to win reliably, it has to inflate the utility's
selector (defeating the point of a utility) or add !important (which needs its own, even less pleasant escalation
once two !important rules collide).
The real conflict is about priority between groups — reset, base, components, utilities, emergency overrides — and
specificity was never designed to express that. @layer lets you say it directly: "utilities always beat
components", with no selector gymnastics and no !important.
Cascade Layer Basics
A cascade layer is a named (or anonymous) group of rules. You declare the order the groups take priority in separately from writing the rules themselves.
Declaring the Order
A layer statement lists layer names, lowest priority first, without any rules:
/* Lowest priority first, highest priority last */
@layer reset, base, components, utilities;Filling Layers In
Later — anywhere in the same stylesheet, or in another one — you add rules to a layer by opening a layer block with its name. A layer can be opened as many times as you like; each block appends rules to it:
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
}
}
@layer components {
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
background: #e5e7eb;
}
.btn--primary {
background: #2563eb;
color: white;
}
}
@layer utilities {
.bg-slate-100 {
background: #f1f5f9;
}
}Layer Order Beats Specificity
Here's the whole point in one sentence: because utilities was declared after components, every rule in
utilities beats every rule in components, whatever their specificity.
.bg-slate-100 and .btn--primary have the same specificity — one class each — and .bg-slate-100 wins. It would
still win if .btn--primary were rewritten as #app .card .btn.btn--primary tomorrow. Layer order is compared first,
and once two rules are known to be in different layers, their specificity is never compared at all.
Visual Illustration
Unlayered Styles Always Win
Here's the rule that catches almost everyone out. A rule that isn't inside any layer — plain CSS, the kind every
stylesheet was made of before @layer existed — has a priority of its own. For normal declarations, unlayered
styles beat every layered style, however the layers are ordered.
@layer utilities {
/* utilities is the last-declared layer, so you might expect this to beat anything */
.text-accent {
color: teal;
}
}
/* Not inside any @layer block */
.text-accent {
color: crimson;
}An element matching both rules is crimson. utilities is the highest-priority layer, but unlayered styles sit
above the layers altogether, as if they were in one last, implicit layer of their own.
Anonymous and Nested Layers
Anonymous Layers
A layer doesn't need a name. An anonymous layer is a layer block with nothing after the keyword:
@layer {
p {
margin-block: 1rem;
}
}It takes its place in the layer order wherever it appears, like a named layer, but it can't be reopened later because there's no name to refer to. Every anonymous block creates a brand-new layer, even if two of them sit back to back. They're handy for a self-contained chunk of CSS that should simply lose to everything declared after it.
Nested Layers
Layers can contain other layers. Declare sub-layers inside a layer, then reach them from outside with dot notation:
@layer components {
@layer button, card, modal;
}
/* Add rules to the nested "button" sub-layer from anywhere */
@layer components.button {
.btn {
padding: 0.5rem 1rem;
}
}Sub-layers are ordered among themselves, inside their parent, so modal beats card, which beats button — and all
three still sit at components' position in the top-level order. This suits design systems that need a fine-grained
order within one tier without knowing anything about the app's top-level layers.
Importing Third-Party CSS into a Layer
For a large codebase, this may be the most useful thing @layer offers. @import accepts a layer() function that
puts an entire external stylesheet into one layer:
@layer reset, vendor, base, components, utilities;
/* Every rule in the imported file lands in the "vendor" layer */
@import url("some-ui-kit.css") layer(vendor);Third-party CSS is exactly what teams used to fight with !important and ID-selector overrides, because you don't
control its selectors. Imported into a layer, every one of its rules — even #id .deeply .nested .selector — is capped
at vendor's position. Declare vendor early, and your own components and utilities beat it without a single
!important.
@import Has to Come First
@import rules must appear before every other rule in a stylesheet, except @charset and layer statements. That
exception exists precisely so you can declare the order and then import into it, as above. Put an @import after a
style rule or a layer block and the browser ignores it entirely.
!important Reverses Layer Priority
This is the rule most worth slowing down for: !important doesn't just sit on top of the layers — it reverses their
order. For normal declarations, a later layer beats an earlier one. For !important declarations, the earlier
layer wins, and unlayered !important declarations lose to every layered one.
@layer base, utilities;
@layer base {
#card .title.title {
color: royalblue !important;
}
}
@layer utilities {
.text-accent {
color: var(--accent) !important;
}
}With !important on both, base — declared first — wins: the opposite of what the same two rules do without it.
Same selectors, same specificity, same layer order.
Visual Illustration
The reversal is deliberate. !important exists so that a lower-priority source can protect a declaration from being
overridden — the same reason a user's !important style beats an author's. A reset or base layer can mark the few
things that must never change, and later layers can't override them by accident. A
global reduced-motion override is a good example: in the reset
layer, its !important durations still beat every animation declared in a later layer.
Rolling Back a Layer with revert-layer
Sometimes a layer should opt out of a property rather than set it. The revert-layer keyword rolls a property
back to the value it would have if the current layer didn't set it — the value from earlier layers:
@layer base, components;
@layer base {
a {
color: #2563eb;
text-decoration: underline;
}
}
@layer components {
.nav a {
color: inherit; text-decoration: none;
}
/* Except the current page's link, which keeps the base link styles */
.nav a[aria-current="page"] {
color: revert-layer;
text-decoration: revert-layer;
}
}The current page's link gets base's blue underline back, without components having to repeat those values — so if
the base link styles change, it follows them. revert-layer works in any property, including the all shorthand,
and shipped in the same browser versions as @layer itself.
The Complete Cascade Sort Order
@layer is easy to misfile as a fourth tiebreaker sitting alongside specificity and source order. It isn't quite
that. When declarations compete, the browser works down a list of criteria and stops at the first one that separates
them. The first criterion is origin and importance, from highest priority to lowest:
- Transitions — a property currently running a CSS transition
!importantuser-agent — the browser's own!importantdefaults (rare)!importantuser — user stylesheets and some browser extensions!importantauthor — your!importantdeclarations. Earlier layers win; unlayered is lowest.- Animations — values from running
@keyframesanimations - Normal author — your ordinary declarations. Later layers win; unlayered is highest.
- Normal user
- Normal user-agent — the browser's built-in defaults
If two declarations tie on origin and importance, the next criteria are applied in turn:
- Context — for Shadow DOM, styles from the outer page versus styles inside a shadow tree
- Inline styles — a
style=""attribute beats any selector-based rule, layered or not - Layer order — the rules from steps 4 and 6 above
- Specificity
- Order of appearance — the later declaration wins
So layers come after origin and importance and before specificity. Two rules in the same layer — or both unlayered — still fall back to specificity, and then to source order, exactly as before. Layers change which rules get compared by specificity, not how specificity works.
Try It Yourself
Reading that layer order beats specificity is one thing; watching a real browser decide is another. The playground
has four real CSS rules, from .util-text (0-1-0) to #panel .body-text.featured (1-2-0), all setting the
color of the same paragraph. #panel and .card are real ancestor elements, so this is a genuine specificity
conflict.
- Use Declared Layer Order to move layers up (lower priority) or down (higher priority)
- In Competing Rules, put each rule in a layer or leave it unlayered, and tick
!importanton any of them - The stylesheet behind the preview is rewritten on every change, so the paragraph's colour is the browser's own decision, and the readout names the winning rule and the criterion that decided it
Try these in order: put the highest-specificity rule in the earliest layer; make one rule unlayered; then add
!important to two rules in different layers and watch the winner flip.
Cascade Layers Specificity War
Assign four real CSS rules of different specificity to declared layers, reorder the layers, and toggle !important per rule — a live stylesheet lets a real browser decide the winner while a readout explains exactly why.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Cascade Layers Playground</title> </head> <body> <main class="playground"> <header class="playground-header"> <h1>Specificity War Playground</h1> <p>Four real CSS rules of very different specificity all target the same paragraph's <code>color</code>. Assign each one to a declared layer (or leave it unlayered), reorder the layers, toggle <code>!important</code> per rule — a real <code><style></code> tag is rewritten on every change, so a real browser decides the winner, not a simulation.</p> </header> <fieldset class="control-group"> <legend>Declared Layer Order (lowest → highest priority)</legend> <ol id="layer-order-list" class="layer-order-list" aria-label="Declared layer order, lowest priority first"></ol> <p class="tip">Layers declared later win, for normal (non-important) rules. Move a layer down to raise its priority — every rule currently assigned to it inherits the new priority instantly.</p> </fieldset> <fieldset class="control-group"> <legend>Competing Rules</legend> <div id="rules-table" class="rules-table"> <div class="rule-row" data-rule="a"> <div class="rule-meta"> <span class="rule-swatch" data-rule="a" aria-hidden="true"></span> <code class="rule-selector">.util-text</code> <span class="rule-specificity">specificity 0-1-0</span> </div> <div class="rule-controls"> <div class="field"> <label for="layer-select-a">Layer</label> <select id="layer-select-a" class="layer-select" data-rule="a"></select> </div> <label class="checkbox-field important-field" for="important-a"> <input type="checkbox" id="important-a" class="important-toggle" data-rule="a"> <span><code>!important</code></span> </label> </div> </div> <div class="rule-row" data-rule="b"> <div class="rule-meta"> <span class="rule-swatch" data-rule="b" aria-hidden="true"></span> <code class="rule-selector">.card .body-text</code> <span class="rule-specificity">specificity 0-2-0</span> </div> <div class="rule-controls"> <div class="field"> <label for="layer-select-b">Layer</label> <select id="layer-select-b" class="layer-select" data-rule="b"></select> </div> <label class="checkbox-field important-field" for="important-b"> <input type="checkbox" id="important-b" class="important-toggle" data-rule="b"> <span><code>!important</code></span> </label> </div> </div> <div class="rule-row" data-rule="c"> <div class="rule-meta"> <span class="rule-swatch" data-rule="c" aria-hidden="true"></span> <code class="rule-selector">#panel .body-text</code> <span class="rule-specificity">specificity 1-1-0</span> </div> <div class="rule-controls"> <div class="field"> <label for="layer-select-c">Layer</label> <select id="layer-select-c" class="layer-select" data-rule="c"></select> </div> <label class="checkbox-field important-field" for="important-c"> <input type="checkbox" id="important-c" class="important-toggle" data-rule="c"> <span><code>!important</code></span> </label> </div> </div> <div class="rule-row" data-rule="d"> <div class="rule-meta"> <span class="rule-swatch" data-rule="d" aria-hidden="true"></span> <code class="rule-selector">#panel .body-text.featured</code> <span class="rule-specificity">specificity 1-2-0</span> </div> <div class="rule-controls"> <div class="field"> <label for="layer-select-d">Layer</label> <select id="layer-select-d" class="layer-select" data-rule="d"></select> </div> <label class="checkbox-field important-field" for="important-d"> <input type="checkbox" id="important-d" class="important-toggle" data-rule="d"> <span><code>!important</code></span> </label> </div> </div> </div> <p class="tip">All four selectors above match the exact same paragraph in the preview — <code>#panel</code> and <code>.card</code> are real ancestor elements, so this is a genuine specificity conflict, not a mocked one.</p> <button type="button" id="reset-btn" class="toggle-btn">Reset to default</button> </fieldset> <section class="demo-area" aria-label="Specificity war preview"> <div id="panel"> <div class="card"> <p id="target-el" class="util-text body-text featured">The quick brown fox jumps over the lazy dog.</p> </div> </div> </section> <output id="war-output" class="readout" aria-live="polite"></output> </main> <script src="./index.js"></script> </body> </html>
Starting sandbox…
No console output yet.
No original version of /index.html to compare against.
Migrating a Large Codebase
A Layer Scheme That Scales
A layering scheme doesn't need to be clever, just consistent. This one suits most codebases that mix a component library with utility classes:
@layer reset, vendor, base, components, utilities, overrides;reset— a reset or normalise stylesheet, often@imported straight into the layer; see A Modern CSS Reset, Explained Rule by Rule for one worth starting fromvendor— third-party CSS you don't controlbase— bare element defaults (body,a, headings, form controls), with no classescomponents— your design system's or component library's classesutilities— small, single-purpose classes that override components case by caseoverrides— a rarely used escape hatch for one-off exceptions, declared last on purpose
Declare the Order Once, First
Put the layer statement at the very top of your entry stylesheet, before any layer is mentioned anywhere — including in files that are imported or concatenated before it. A layer's position is fixed the first time its name appears (see pitfall 4), so one statement in one place keeps the order predictable.
Migrate from the Edges Inwards
Because unlayered CSS beats everything layered, migration is safe to do gradually — as long as you go in the right direction:
- Start with
resetandbase. Everything else is expected to override them anyway, so moving them into layers, where they lose to all the CSS that's still unlayered, changes nothing visible. - Then
vendor. Import third-party CSS into its layer; your own unlayered CSS keeps beating it, as it should. - Move
componentsandutilitieslast, and together if you can. Layering a component while an unlayered rule elsewhere still competes with it makes that unlayered rule win unconditionally — the one way a migration can visibly break something.
Utility Classes Last
This is the pattern many teams adopt first: put utility classes in the last layer, so they win over components whatever the component selectors look like.
@layer reset, base, components, utilities;
@layer components {
.card .title {
font-weight: 600;
color: #111827;
}
}
@layer utilities {
/* One class in the last layer — it wins over any component selector */
.text-accent {
color: var(--accent);
}
}Without layers, .card .title (0-2-0) beats .text-accent (0-1-0) every time, which is why utility-first
frameworks used to need escape hatches such as a global "make every utility !important" setting or a ! prefix on
individual classes. With utilities declared last, none of that is needed. Tailwind CSS v4 takes exactly this
approach: it uses real cascade layers — theme, base, components and utilities — rather than emulating them.
Debugging Cascade Layers
Browser DevTools. Chrome, Edge, Firefox and Safari all show the layer a rule belongs to in the Styles panel of the
element inspector, next to the rule itself. Rules that lose are crossed out as usual. When a rule doesn't apply, check
three things in order: is it in an earlier layer than the rule that won, is the winner unlayered, and is either rule
!important?
Listing layers from JavaScript. Layer blocks appear in the CSS object model as CSSLayerBlockRule objects, and
layer statements as CSSLayerStatementRule objects, so a quick console snippet can list the blocks your page
defines:
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSLayerBlockRule) {
console.log(rule.name || "(anonymous)");
}
}
} catch {
// Cross-origin stylesheets can't be read from script
}
}This only walks the top level of each stylesheet; nested layers and layers inside @imported files sit one level
deeper, in each rule's own cssRules.
Browser Support and Fallbacks
@layer shipped in Chrome and Edge 99, Firefox 97 and Safari 15.4 in early 2022. It has been Baseline since then, and
"widely available" — supported for long enough to use without a second thought — since late 2024. revert-layer
shipped in the same versions.
What Happens Without Support
In a browser that doesn't understand @layer, the entire contents of every layer block are dropped — not
unwrapped into ordinary CSS. That's standard error handling for an unknown at-rule, and it's harsher than "the
layering stops working": anything written only inside a layer never reaches that browser. An @import with
layer() is ignored altogether, too.
Given how long @layer has been supported, this only matters if your analytics show real traffic from browsers older
than 2022. If they do, keep what's essential — a working layout and readable text — available as unlayered CSS, or use
a build tool that flattens layers into ordinary CSS for those targets.
Feature Detection
@supports tests property–value pairs, and @layer is an at-rule, so an ordinary @supports query can't detect it.
A @supports at-rule() test has been proposed, but it isn't something you can rely on across browsers yet. Be sceptical
of snippets that claim to detect @layer with @supports. In JavaScript, though, detection is easy:
const supportsLayers = "CSSLayerBlockRule" in window;Common Pitfalls
1. Assuming @layer Changes Specificity
It doesn't. Specificity is calculated exactly as before; it's just no longer compared between layers. Two rules in the same layer, or both unlayered, are still decided by specificity and then source order.
2. Forgetting That Unlayered CSS Wins
For normal declarations, any rule outside every layer beats all layered rules. If a layered rule isn't applying, look for an unlayered rule targeting the same property before you suspect the layer order.
3. Forgetting That !important Reverses the Order
Among !important declarations, the earliest layer wins and unlayered is the lowest priority — both the opposite
of normal declarations. A utility marked !important in the last layer loses to an !important rule in reset.
4. A Layer Fixed in the Wrong Place by Accident
A layer's position is fixed the first time its name appears anywhere — in a layer statement or a layer block. A later statement can't move a name that has already appeared:
/* ❌ utilities appears first, so it becomes the FIRST (lowest-priority) layer.
The statement below can only place the names it hasn't seen yet. */
@layer utilities {
.text-accent {
color: var(--accent);
}
}
@layer reset, base, components, utilities;/* ✅ The full order is declared before any layer is mentioned */
@layer reset, base, components, utilities;
@layer utilities {
.text-accent {
color: var(--accent);
}
}In the first example, the final order is utilities, reset, base, components — the reverse of what the statement
seems to promise. This happens easily when a bundler concatenates files in an unexpected order.
5. An @import After Other Rules
@import must come before every rule except @charset and layer statements. Placed after a style rule or a layer
block, @import url("vendor.css") layer(vendor); is silently ignored, and the vendor styles never load.
6. Expecting Layers to Affect Stacking
The name is a coincidence. @layer decides which declarations win; it has nothing to do with which elements paint
on top. For that, see Mastering CSS z-index.
Best Practices
- Declare the full order once, at the top of your entry stylesheet, before any layer is mentioned anywhere.
- Give third-party CSS its own early layer with
@import url(...) layer(vendor);. - Put utilities last, on purpose. It's the cheapest layering decision with the biggest payoff.
- Keep
!importantfor genuine emergencies. Its reversed layer order is an escape hatch, not an architecture. - Migrate from the edges inwards:
resetandbasefirst,componentsandutilitieslast. - Declare tiers you might need, even empty. New top-level layers can only be added after existing ones.
- Remember specificity still applies within a layer. Layers change what's compared across groups, not how a tie inside one is resolved.
Frequently Asked Questions
Wrapping Up
@layer doesn't make specificity go away, and it isn't a bigger hammer than !important. It's a second, explicit
axis of priority, checked before specificity, that lets you say once that one tier of your architecture always beats
another, instead of arguing it out selector by selector. The few rules that make it work — unlayered styles win,
!important reverses the order, and a layer's position is fixed when its name first appears — are worth learning
before you use it in a real codebase, because they're exactly where an otherwise correct mental model breaks.
Start small: put your existing component styles in one layer and your utilities in a later one. That one change ends
the specificity fight this post opened with, without rewriting a selector or adding a single !important. And if
your layout problems are about what's on top rather than what wins, the
Container Queries Deep Dive and
Mastering CSS z-index posts pick up where this one leaves off.
This one is built around three diagrams — a one-class utility beating an ID selector through layer order alone, the same two rules swapping places the moment both turn !important, and an eight-rung ladder that puts @layer exactly where the cascade consults it — and a playground that rewrites a real <style> tag on every change, so four rules of rising specificity genuinely fight it out in your own browser. Getting its readout to name the criterion that actually decided each result — layer order, specificity, the !important reversal, or unlayered styles winning outright — took more edge-case testing than I'd like to admit. I hope the next time a utility class refuses to apply, you reach for a layer instead of another !important. Thanks for picking a side in the specificity war with me. 🗂️