Skip to main content

Harshal V. LADHE

CSS Cascade Layers (@layer): End Specificity Wars in Large Codebases

Control CSS priority with layer order, not specificity.
Published at:
Last updated:
Estimated reading time:20 min read

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 @layer exists 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 !important reverses layer priority, and how revert-layer rolls a layer back
  • The complete cascade sort order, with @layer in 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

Layer order beats specificity diagramTwo competing CSS rules. The first, in a layer named base declared first, targets #card .title.title — an ID plus two classes, high specificity. The second, in a layer named utilities declared last, targets .text-accent — a single class, low specificity. Despite its much lower specificity, the utilities rule wins, because its layer was declared after base is.@layer base, utilities;baseutilitiesdeclared later → higher priorityLayer: base (declared 1st)#card .title.title {color: royalblue;}specificity 1, 2, 0(id + 2 classes — high)✕Layer: utilities (declared 2nd).text-accent {color: var(--accent);}specificity 0, 1, 0(1 class — low)✓<p class="title text-accent">renders in the utilities colorLayer order decides it — specificity never even gets compared across layers.

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

important reverses cascade layer priority diagramThe same base and utilities layers and the same two selectors as the layer-order-beats- specificity diagram, but both declarations now carry the important flag. The winner flips: base, declared first, now beats utilities, declared last — because for important declarations, layer priority runs in the opposite order from normal declarations.@layer base, utilities; (both rules !important)baseutilitiesfor !important, priority flips: declared earlier → higher priorityLayer: base (declared 1st)#card .title.title {color: royalblue !important;}✓Layer: utilities (declared 2nd).text-accent {color: var(--accent) !important;}✕<p class="title text-accent">renders in the base color — the flipSame rules, same layer order — only !important changed, and the winner flipped.

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:

  1. Transitions — a property currently running a CSS transition
  2. !important user-agent — the browser's own !important defaults (rare)
  3. !important user — user stylesheets and some browser extensions
  4. !important author — your !important declarations. Earlier layers win; unlayered is lowest.
  5. Animations — values from running @keyframes animations
  6. Normal author — your ordinary declarations. Later layers win; unlayered is highest.
  7. Normal user
  8. Normal user-agent — the browser's built-in defaults

If two declarations tie on origin and importance, the next criteria are applied in turn:

  1. Context — for Shadow DOM, styles from the outer page versus styles inside a shadow tree
  2. Inline styles — a style="" attribute beats any selector-based rule, layered or not
  3. Layer order — the rules from steps 4 and 6 above
  4. Specificity
  5. 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.

Complete CSS cascade sort order with cascade layers diagramAn 8-rung vertical ladder, highest priority at the top: transitions; important user-agent; important user; important author; animations; normal author; normal user; normal user-agent. Only the two author rungs involve cascade layers — inside the important author rung, the earlier-declared layer wins, and unlayered important styles are the lowest priority. Inside the normal author rung, unlayered styles are the highest priority, and among layers the later-declared one wins. A side note lists the tiebreakers for declarations that share a rung, in order: inline style attribute, layer order on the author rungs only, specificity, then source order.Cascade sort order — highest priority firsthigher priorityTransition declarationsa property actively animating via a CSS transition!important — user-agentthe browser's own !important defaults (rare)!important — useruser stylesheets / browser extensions, !important!important — authorunlayered = lowest; among layers, the EARLIER layer wins@layerAnimationsvalues from running @keyframes animationsNormal — authorunlayered = highest; among layers, the LATER layer wins@layerNormal — useruser stylesheets / browser extensions, normalNormal — user-agentthe browser's own built-in default stylesTies within a rung, in order:① Inline style=""② Layer order*③ Specificity④ Source order* only on the two@layer rungs@layer only separates declarations that already share one of the two author rungs.

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 !important on 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>&lt;style&gt;</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>

Ln –, Col –HTML5.4 KBUTF-8

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 from
  • vendor — third-party CSS you don't control
  • base — bare element defaults (body, a, headings, form controls), with no classes
  • components — your design system's or component library's classes
  • utilities — small, single-purpose classes that override components case by case
  • overrides — 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:

  1. Start with reset and base. 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.
  2. Then vendor. Import third-party CSS into its layer; your own unlayered CSS keeps beating it, as it should.
  3. Move components and utilities last, 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 !important for genuine emergencies. Its reversed layer order is an escape hatch, not an architecture.
  • Migrate from the edges inwards: reset and base first, components and utilities last.
  • 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. 🗂️