CSS

Cascade layers changed how I write CSS

Twelve years of fighting specificity, solved by a single at-rule I ignored for two years because it sounded like a build-tool thing. How I structure a stylesheet now, and what I deleted to get there.

I read the @layer spec in 2020 and filed it under "build tooling". The name is doing it no favours: it sounds like a bundler concept, another abstraction between me and a stylesheet I could already read top to bottom. So I ignored it for two years.

Then I inherited a stylesheet where a card title was styled by eleven rules across four files, two ending in !important, and the winner was a four-class descendant chain in a file named _fixes.scss. I spent a day deleting things until the cascade landed where I wanted.

The problem layers actually solve

It is tempting to call layers a specificity fix. They are not: a layer does not change the specificity of a single selector, and inside a layer nothing about the old rules changes. What layers fix is the ordering problem.

Every stylesheet you ship lives in one flat origin. Your reset, a vendor component library, page styles, utilities, and whatever got pasted in during launch week all compete in the same ranking, and the tie-breakers are selector weight and source order. That is fine in a small project and corrosive in a large one, because your only two levers are make the selector heavier or move the file later in the import list. Both are global and invisible.

The tax shows up as specific shapes. A modifier class that exists only to outrank a base class, not to express a state anyone can name. A utility layer that has to be imported last. A progressively deeper chain, because .card__title stopped being enough and .checkout .card .card__title was next. And !important, used as a tie-breaker rather than the rare safety valve it was designed to be.

Layers give you a third lever: a named, ordered list that sits above specificity in the cascade. Two rules of equal weight in different layers stop caring about source order, import order, or file load order. They care about the order you declared once, deliberately.

My layer order

The whole architecture is one line, in the entry stylesheet:

/* app.css — first statement in the file, before every @import */
@layer reset, tokens, base, layout, components, utilities, overrides, legacy;

@import url("./reset.css");
@import url("./tokens.css");
@import url("./base.css");
/* …layout, components, utilities… */
@import url("./legacy/checkout.css");

Declaring the order first has a consequence people miss: the order of the @import statements below it stops mattering. Before layers, my import block was load-bearing, and moving a line silently changed the site. Now the block is alphabetical and the cascade is unchanged. I traded control at the import site for a guarantee that ordering cannot drift.

Reasoning, layer by layer:

  • reset, tokens — earliest, so everything overrides them without effort.
  • base — element selectors: body, a, h2, form controls. No classes, no chains.
  • layout — grid and container primitives such as .stack and .sidebar, which need to lose to components. A later layer gives me that for free.
  • components — the bulk of the stylesheet, where I now use sublayers: @layer components.forms, components.cards, components.nav. A sublayer order makes a component conflict explicit rather than accidental.
  • utilities — after components, so a single class such as .mt-0 genuinely beats a component rule without !important. This one ordering decision deleted more code than any other.
  • overrides — a small, reviewed layer for legitimate exceptions and third-party patches.
  • legacy — last, for files I have not migrated yet. More on this below.

Unlayered CSS beats every layer, because the unlayered origin sits above all of them. I use that deliberately for the print and reduced-motion sheets, which are supposed to win unconditionally. Everywhere else we banned it with a lint rule.

Normal declarations rank by layer order, low to high. Add !important and the order inverts: unlayered !important becomes the weakest thing in the cascade, and !important in the first layer becomes the strongest.

The bit of the spec I had to read four times before I believed it

What I deleted

The interesting part was not the new code. It was how much of the old code existed only to lose a specificity fight, and how quickly it became deletable once order settled the fight instead.

  • BEM modifiers that only won specificity. Sixty-odd --compact, --elevated and --wide variants that duplicated the base block with one property changed.
  • Forty-one !important declarations. I kept three, all in the print sheet.
  • Descendant chains. .page-checkout .card .card__title and seventeen relatives, each now one or two classes, because depth had been a substitute for ordering.
  • Utility framework ordering hacks. The vendor's comment told you to import its utilities last, a load-order dependency that bundlers routinely destroy.
  • A dead !important audit script. It had reported the same count in CI for a year.

Net: about 1,100 lines and 250 selectors removed from a stylesheet of roughly 6,400 lines, and minified CSS down from 48 KB to 34 KB. I want to be honest about the performance story, because it is usually oversold: style recalculation was already in the low single-digit milliseconds and stayed there. The real win was that I could explain, in one sentence, why any given rule won.

Where layers surprised me

Unlayered CSS still wins

This one bit us in production. A teammate added a .badge rule at the bottom of a legacy partial that had not been wrapped in a layer, tested the badge, and shipped. It worked — and it also overrode the badge styles in the component library, because unlayered declarations outrank every layer regardless of where they appear or how weak the selector is. A bare .badge in an unlayered file beats a five-class chain in overrides. Nothing in a normal code review surfaces that. It is why our unlayered-selector lint rule is a build failure rather than a warning.

A useful framing

Layers are a filing cabinet; specificity is what happens inside one drawer. A rule in a later drawer beats a rule in an earlier one, however specific that earlier selector was — and anything left on the desk outside the cabinet beats all of it.

Third-party stylesheets

Wrapping a vendor sheet in a layer is the feature that sold me on the whole thing:

@import url("./vendor/datepicker.css") layer(vendor);

One line, and every rule in that file loses to the rest of my stylesheet. The vendor keeps shipping patches and my overrides do not change. What breaks is subtler:

  • The vendor's own @imports resolve relative to its URL, so a component importing a sibling sheet pulls that sheet into vendor too.
  • If the vendor uses !important — and many do, on display and position — layer inversion means their "nuclear option" now outranks later layers instead of losing to them.
  • A vendor sheet that injects style tags at runtime with CSS-in-JS is unlayered by construction, and no @layer will contain it.

The genuinely wrong thing we did: declare the vendor layer first, ahead of reset, because that read as "lowest priority". It was, and it broke the vendor's internal ordering assumptions. vendor between base and components fixed it.

Layers and :where() / @scope

Layers and :where() compose well and are not alternatives. :where() zeroes the specificity of what is inside it, which matters within a layer; layers decide what happens between them. I reach for :where() for anything that should be trivially overridable inside its own layer, and layers for everything else.

@scope is the more interesting overlap, because it adds a second, orthogonal ordering mechanism: proximity. Inside one layer you can have a scoped and an unscoped rule with identical specificity, and the scoped one wins when its root is closer in the DOM. I have used it to replace a set of descendant chains in a rich-text partial — @scope (.article-body) { … } — without touching the layer order. Support is the caveat: check your browser matrix first. It also made @scope safer to try, because if a browser ignores the scoped block, the unscoped rule in an earlier layer still applies and the page degrades instead of collapsing.

Adopting layers in an existing codebase

I have done this twice on inherited code, and the order of operations matters more than the layer list:

  1. Declare the order first, change nothing else. Ship the @layer statement alone. The site is identical, the ordering is frozen, and every later migration is a small, reviewable diff.
  2. Wrap the reset, then the tokens, then the base styles. Low-risk on their own, and they establish the pattern so the next person copies it.
  3. Move utilities next. Immediate payoff: they stop needing to be imported last and stop needing !important.
  4. Components one at a time, in a sublayer each. Never in one commit. This is the long tail, and where the deletions happen.
  5. Park everything untouched in legacy, at the end. The layer name is an honest inventory of the work remaining. When it is empty, delete the declaration.

For linting, a stylelint rule that requires every top-level selector to be inside a layer is about twenty lines, and it is worth writing yourself because the failure mode is so quiet. Ours allows an explicit opt-out comment on the print and reduced-motion sheets and fails the build on anything else. DevTools help too: modern Chromium and Firefox show the layer name beside each matched rule, which turns a cascade investigation from inference into reading.

What it changed day to day

Fewer one-off overrides, because the reason to write one has mostly disappeared. The stylesheet is smaller. Ordering is reviewable: a change to the layer list is a visible, deliberate diff rather than an invisible consequence of moving an import.

The moment I noticed it had landed was unglamorous. I was fixing a spacing bug in a modal, and the fix was to delete an !important — one I had added four years earlier with a comment that said /* needed */. The spacing held, and I have not added one to application CSS since.

Two years of ignoring the at-rule cost more than reading the spec did. I keep re-learning that the CSS I write is shaped by the tools I assume are for someone else.

Portrait of Elliot Vance

Elliot Vance

Software engineer in Rotterdam. I help teams make their systems fast, observable and boring, usually by removing more than I add. Eleven years in, mostly on web performance, databases and platform work.

More about me