Cascade Layers and Specificity Control

Article · 14:00

Specificity wars end the same way every time: a selector, then another selector, then !important, and the file is unmaintainable. Cascade layers remove the argument by making stylesheet order something you write down deliberately.

This lesson has 1 runnable example. Press Try yourself! under any of them to open it in the editor.

Cascade Layers and Specificity Control

Specificity wars end the same way every time: somebody adds a selector, then somebody adds another, then somebody writes !important and the file is now unmaintainable. Cascade layers remove the argument by making stylesheet order something you declare rather than something that emerges from the order files happen to load in.

The problem, precisely

/* components.css - written carefully */
.button { background: blue; }

/* some-library.css - loaded after, and you do not control it */
.lib .btn.btn-primary { background: red; }

Your rule is 0-1-0. Theirs is 0-3-0. Theirs wins, and your options are all bad: raise your specificity to match, add !important, or wrap everything in an extra class and raise it everywhere.

Each of those makes the next conflict worse, because you have raised the floor.

Layers decide before specificity does

@layer reset, base, components, utilities;

@layer components {
  .button { background: blue; }         /* 0-1-0 */
}

@layer base {
  .lib .btn.btn-primary { background: red; }   /* 0-3-0 */
}

The blue wins. components is declared after base, and layer order beats specificity entirely.

That first line is the important one: it declares the order up front, before any of the layers have content. After that it does not matter what order the files load in, which is what makes this robust - a build step that reorders imports, or a third-party stylesheet that arrives late, no longer changes anything.

The full cascade order, revised

With layers, the order a browser works through is:

  1. Origin and importance
  2. Layer order - later layers win
  3. Specificity
  4. Source order

And one rule that catches everybody: unlayered styles beat every layer.

@layer everything {
  .button { background: blue; }   /* 0-1-0, in a layer */
}

.button { background: red; }      /* 0-1-0, not in a layer - wins */

This is deliberate. It means you can adopt layers incrementally: put the library in a layer, leave your own styles unlayered, and yours win without any specificity changes at all. It also means a stray unlayered rule will quietly override a carefully ordered system, so a codebase that uses layers should use them for everything.

Importance inverts the order

@layer a, b;
@layer a { .x { color: red !important; } }
@layer b { .x { color: blue !important; } }

Red wins. For !important declarations the layer order is reversed - the earliest layer wins. The reasoning is that a reset layer's emergency override should not be beatable by a utility layer's emergency override, and the same inversion is what lets a user stylesheet beat an author one.

The practical advice is unchanged: in a layered system you should almost never need !important.

A layer order that works

@layer reset, base, layout, components, utilities, overrides;
  • reset - normalisation. Lowest, so anything can beat it.
  • base - element defaults: typography, links, form controls.
  • layout - page-level structure.
  • components - buttons, cards, the rail.
  • utilities - single-purpose classes. Above components deliberately, so .mt-0 wins against a component's margin without !important.
  • overrides - page-specific exceptions, rare and visible.

A third-party stylesheet goes near the bottom:

@layer reset, vendor, base, components, utilities;

@import url("bootstrap.css") layer(vendor);

@import with layer() puts a file you do not control into a layer you do, and now your components beat it regardless of how its selectors are written. This one line replaces an entire category of workaround.

Anonymous and nested layers

@layer {                        /* anonymous - cannot be added to later */
  .one-off { color: red; }
}

@layer components {
  @layer buttons, cards;        /* nested, ordered within components */

  @layer buttons { .button { } }
  @layer cards   { .card { } }
}

Nested layers are addressed as components.buttons. Useful in a large system; unnecessary in a small one.

:where() and :is(), the other half of specificity control

:where(.a, .b) { }    /* always 0-0-0 */
:is(.a, #b) { }       /* takes the HIGHEST of its arguments: 1-0-0 */

:where() contributes nothing to specificity, which makes it the right tool for anything that should be easy to override:

/* A reset that loses every argument, by design */
:where(h1, h2, h3, h4) { margin-block: 0 0.5em; }
:where(ul, ol) { padding-inline-start: 1.5em; }
:where(a) { color: var(--accent); }

A single class beats any of those, with no layer involved.

:is() is for grouping without repetition, and its specificity surprises people:

/* 1-0-0 - the #sidebar lifts the whole thing */
:is(#sidebar, .main) a { }

/* 0-1-0 - :where neutralises it */
:where(#sidebar, .main) a { }

Both also change how a selector list fails. In a plain comma list, one unsupported selector invalidates the whole rule; inside :is() or :where(), an unknown selector is simply ignored and the rest still match. That makes them useful for forward compatibility.

@scope, for styles that stop

@scope (.card) to (.card-body) {
  p { color: grey; }     /* paragraphs in the card, but not in the body */
}

@scope sets a root and an optional lower boundary. Styles apply between them. The lower boundary is the genuinely new part - a "donut" of styles that stops at a nested region, which previously needed either :not() gymnastics or a different class on everything.

It also changes how proximity is resolved: when two scoped rules match, the one whose scope root is closer to the element wins, before specificity is considered. That is what makes nested components behave the way people expect:

@scope (.theme-dark) { a { color: white; } }
@scope (.theme-light) { a { color: black; } }

A link inside a light panel inside a dark page gets black, because .theme-light is nearer. No specificity arithmetic involved.

Support is newer than layers, so treat it as an enhancement for now rather than the foundation.

Putting it together

@layer reset, base, components, utilities;

@layer reset {
  :where(*, *::before, *::after) { box-sizing: border-box; }
  :where(body) { margin: 0; }
  :where(img, svg, video) { display: block; max-width: 100%; }
}

@layer base {
  :where(h1, h2, h3) { line-height: 1.2; text-wrap: balance; }
  :where(p) { max-width: 70ch; }
}

@layer components {
  .button { background: var(--accent); padding: .6rem 1rem; }
  .card { background: var(--surface); border-radius: .5rem; }
}

@layer utilities {
  .mt-0 { margin-block-start: 0; }
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; }
}

Every selector is one class or less. There is no !important anywhere. A utility beats a component because of where it sits, not because of how it is written. And a new rule cannot start an escalation, because there is nothing to escalate against.

A page you can run

Four layers, a utility that wins without !important, and a third-party widget quarantined underneath everything.

HTML Example 1 109 lines
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Layers, in order</title>
    <style>
      /* The order is declared once, before anything is written into the
         layers. After this line the order is fixed, and where each rule
         is physically written stops mattering. */
      @layer reset, vendor, base, components, utilities;

      @layer reset {
        *, *::before, *::after { box-sizing: border-box; margin: 0; }
      }

      @layer vendor {
        /* Pretend this came from a widget's own stylesheet. It is as
           specific as a vendor stylesheet usually is - and it loses
           anyway, because its layer is early. */
        #widget .widget-button.widget-button-primary {
          background: #7c3aed;
          border-radius: 0;
          font-family: Georgia, serif;
        }
      }

      @layer base {
        body {
          font: 16px/1.6 system-ui, sans-serif;
          padding: 2rem;
          background: #fbfbfd;
          color: #16181d;
        }
        h1 { font-size: 1.5rem; margin-block-end: 1rem; }
        p { margin-block-end: 1rem; max-width: 60ch; }
      }

      @layer components {
        /* One class. No ID, no !important, no chain of parents - the
           layer is doing the work that specificity used to be asked to
           do. */
        .button {
          font: inherit;
          padding: 0.5rem 0.9rem;
          border: 1px solid #0f766e;
          border-radius: 8px;
          background: #0f766e;
          color: #fff;
          cursor: pointer;
        }
        .button:focus-visible { outline: 2px solid #16181d; outline-offset: 2px; }

        /* :where() takes the specificity to zero, so a card can set
           its own heading size without a fight. */
        .card :where(h2) { font-size: 1.05rem; margin-block-end: 0.25rem; }

        .card {
          background: #fff;
          border: 1px solid #e3e5ea;
          border-radius: 10px;
          padding: 1rem 1.25rem;
          margin-block-end: 1rem;
          max-width: 30rem;
        }
      }

      @layer utilities {
        /* Last layer, so it wins over components - which is exactly
           what a utility is for, and the reason none of these need
           !important. */
        .mt-0 { margin-block-start: 0; }
        .quiet { color: #5c626e; }
        .full { width: 100%; }
      }

      /* Outside every layer. Unlayered styles beat all layers, which is
         the right place for a one-off page override - and the wrong
         place for anything you intend to reuse. */
      .page-note { border-inline-start: 3px solid #0f766e; padding-inline-start: 0.75rem; }
    </style>
  </head>
  <body>
    <h1>The order is the stylesheet</h1>

    <p class="page-note">
      The vendor rule below uses an ID and two classes. The component rule
      uses one class. The component rule wins, because its layer comes later -
      specificity is only consulted within a layer.
    </p>

    <div class="card" id="widget">
      <h2>A quarantined widget</h2>
      <p class="quiet">
        Teal, square-cornered, and in the page font - none of which the
        vendor stylesheet asked for.
      </p>
      <button type="button" class="button widget-button widget-button-primary full">
        Still ours
      </button>
    </div>

    <div class="card">
      <h2>A plain card</h2>
      <p class="quiet mt-0">The utility class wins over the component margin.</p>
      <button type="button" class="button">Also ours</button>
    </div>
  </body>
</html>

Open this example in the editor and change it. Nothing you type is saved.

Open DevTools and inspect the button inside the widget card. You will see the vendor declarations struck through under the component ones, with the layer names shown beside them. That is the whole argument: an ID selector lost to a single class, predictably, and nobody wrote !important to make it happen.

Then move the @layer declaration line so vendor comes last, and reload. The button turns purple and square. The order of that one line is now the contract your stylesheet runs on - which is why it belongs at the top, written once, where a reviewer can see it.

What goes wrong

Symptom Cause
A layered rule loses to a plain one Unlayered styles beat every layer
Layer order seems ignored The order was never declared, so it followed first appearance
!important in an early layer beats a later one Importance inverts the order - working as designed
:is() raised the specificity unexpectedly It takes the highest of its arguments
A whole rule stopped matching One unsupported selector in a plain comma list
Scoped styles leak into a nested component No lower boundary on @scope

The habit

Declare the layer order in one line, at the top, in one file. Write resets with :where(). Import third-party CSS into a low layer. Keep every selector to one class.

Do that and specificity stops being something you think about - which is the actual goal. The best outcome of this lesson is never needing most of it.