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
.stackand.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-0genuinely 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
The bit of the spec I had to read four times before I believed it!importantand the order inverts: unlayered!importantbecomes the weakest thing in the cascade, and!importantin the first layer becomes the strongest.
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,--elevatedand--widevariants that duplicated the base block with one property changed. -
Forty-one
!importantdeclarations. I kept three, all in the print sheet. -
Descendant chains.
.page-checkout .card .card__titleand 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
!importantaudit 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 intovendortoo. -
If the vendor uses
!important— and many do, ondisplayandposition— 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
@layerwill 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:
-
Declare the order first, change nothing else. Ship the
@layerstatement alone. The site is identical, the ordering is frozen, and every later migration is a small, reviewable diff. - 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.
-
Move utilities next. Immediate payoff: they stop needing to be
imported last and stop needing
!important. - Components one at a time, in a sublayer each. Never in one commit. This is the long tail, and where the deletions happen.
-
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.