I can tell you the exact line where I gave up. It was in an app stylesheet, and it looked like this:
.page .panel .toolbar button.btn.btn--primary {
background: #1f6feb !important;
}
Six selectors deep, a doubled class, and an !important at the end for insurance. And the thing is, I knew why every piece of it was there. Someone needed a primary button to be blue on one screen. The design system's own rule was already specific enough to beat a single class, so they added a parent. That worked until the design system shipped a .btn--primary:not(:disabled) refinement, which beat the parent. So they added another parent. Then a doubled class. Then, eventually, the !important, because by that point nobody could hold the whole picture in their head and it was 4pm on a Thursday.
Nothing in that history is unreasonable. Every single step was the cheapest available fix. The problem is that the cheapest available fix, repeated, produces a stylesheet where the winner of any given declaration is genuinely unpredictable — you can't reason about it, you can only test it and see. And once !important enters the vocabulary, the next override has nowhere to go but a second !important with more specificity, which is a war with no armistice because there is no top.
The reset that changed how I think about this came from stating the problem plainly. We weren't disagreeing about specificity. We were disagreeing about precedence, and specificity was just the only tool either side had to express it.
Layers move the decision from selector weight to declared order
@layer creates named buckets with an explicit order, and the order beats specificity outright. A declaration in a later layer wins over one in an earlier layer regardless of how the selectors compare. A single class in a later layer beats a six-deep selector in an earlier one, and it does it without an !important anywhere.
You declare the order once, at the top, before anything else:
@layer reset, tokens, components, utilities;
That one line is the architecture. reset is weakest, utilities strongest, and every rule you write afterward gets sorted into place by name rather than by how hard it fought. Then the design system's button lives in components and the app's override lives in utilities:
@layer components {
.btn.btn--primary:not(:disabled) {
background: var(--brand);
}
}
@layer utilities {
.u-bg-blue {
background: #1f6feb;
}
}
The markup that resolves this is unremarkable, which is the improvement:
<button class="btn btn--primary u-bg-blue">Save</button>
The button renders blue. Not because .u-bg-blue is a stronger selector — it's dramatically weaker, one class against two classes and a pseudo-class — but because utilities was declared after components. The six-selector monstrosity and the !important both went away, replaced by a decision that was made once, in one place, and is visible to anyone who opens the file.
There's a detail in the ordering worth knowing before it bites you: a layer's position is fixed by where its name first appears, not by where you add rules to it. Re-opening @layer components { … } later in the file appends to the existing layer and doesn't move it. That's what makes the single declaration line at the top trustworthy — it establishes the whole order up front, so a stylesheet imported later can't reorder your architecture behind your back by mentioning a layer name.
Two rules that decide whether the migration goes well
The first one is the one that surprises people, and it's the reason a half-finished migration feels broken: unlayered styles beat every layered style. All of them. Anything not inside a layer sits above the entire stack.
Read that as good news about intent and bad news about sequencing. It's deliberate — it means adopting layers is backward-compatible, since your existing unlayered CSS keeps winning while you migrate. But it also means something sharp. Move the design system into @layer components, and every stray unlayered rule in the app is now stronger than it — including the ones that were losing yesterday. If you migrate the library and not the app, you haven't organized the cascade. You've handed unconditional victory to whatever hasn't been layered yet, and the failure looks like the library's styles evaporating for reasons nobody can trace. So layer the app's own CSS in the same effort, and treat any remaining unlayered rule as the deliberate escape hatch it now is.
The second: !important reverses layer order. An !important declaration in your earliest layer beats an !important in your latest one. That's consistent once you see the logic — importance exists so foundational rules can defend themselves — but it will read as broken if you carry the normal-order intuition into it. The practical consequence is that layers don't make !important safe to sprinkle around; they make it mostly unnecessary, which is better.
The alternatives, and what each one actually costs
Escalating specificity is the honest baseline, since it's what most codebases do. It has one real advantage: it needs no coordination, no migration, and no agreement from anyone. That's also its whole problem, though. Each escalation is local and permanent, and the ceiling keeps rising. Worse, the weight says nothing about intent: a six-deep selector doesn't tell you the app should beat the library, only that someone was losing.
!important is the same strategy with the timeline compressed. I'd defend it in exactly two places: a genuine emergency fix you intend to remove, and overriding third-party CSS you don't control and can't layer. Outside those, it converts a precedence question into a binary you can't refine later, and the next person's only move is to escalate past you.
Naming conventions — BEM and its relatives — are the alternative I want to be most careful with, because I use them and recommend them. Flat, single-class selectors like .btn--primary genuinely do reduce specificity conflicts, and a disciplined namespace keeps a library from colliding with an app. But a convention answers collision, not precedence. When the app and the library both want to style the same button, BEM leaves them at equal specificity and hands the decision to source order. Source order is decided by your bundler's import graph. That's not an architecture; it's a coincidence you're depending on. BEM and layers aren't competitors, and pairing them is the setup I'd actually argue for: the convention keeps selectors flat and readable, the layers decide who wins.
Which is why @layer is the recommendation, with the migration cost stated plainly rather than waved past. You have to layer your existing styles, in a coordinated pass, or the unlayered-wins rule will make things worse before it makes them better. Browser support is no longer the blocker it once was, but the human coordination is real — this is a decision a team makes together, not one you land on your own on a Friday afternoon. What you get for it is a stylesheet where the answer to "why did this win?" is a line you can read instead of a score you have to compute.
Precedence Is a Decision, Not a Side Effect
That !important wasn't a failure of CSS knowledge. Everyone involved understood specificity fine; they just had no way to say what they meant. The only vocabulary available for "the app should be able to override the design system here" was selector weight, so the intent got encoded as an arms race and became unreadable within a year. Cascade layers give that intent a name and a place — one ordered list, declared once, that any engineer can read in five seconds and any reviewer can argue with directly. Decide who wins on purpose, write it down where the whole team can see it, and specificity goes back to its actual job: telling apart two rules that were always meant to be peers.


