
You’ve been there. A stylesheet that started life pristine is now a battleground of .modal-header h2.title, nav ul li a.active, and the dreaded !important dropped like a hand grenade because nothing else worked. Every new feature adds one more layer of selectors heavy enough to defeat what was already there. The team calls it “just the way the codebase is.” The senior dev calls it “specificity hell.” It’s the same problem.
Specificity wars happen because of a single property of CSS that nobody can opt out of by default: when two rules target the same element, the more specific selector wins. Add an ID. Nest a tag. Stack a class. Each move beats the previous one, and the cascade rewards escalation. The result is exactly what you’d expect from any system that rewards escalation: monotonic increase in selector weight, no path back down.
The good news is that there are three well-understood patterns that take the war off the table entirely. Not “manage the war better” — actually end it. They’re not new, but they’re underused, and they pair well: pick one, commit, and you stop spending engineering time on this class of problem.
Pattern 1: Flat selectors with a naming discipline
BEM (Block-Element-Modifier) and its cousins exist for one reason: if every component is named uniquely and selectors stay single-class, specificity stays flat. .modal__title, .modal__title--centered, and .button--primary all have the same specificity (one class). When two rules collide, source order decides — which is a much easier mental model than counting selector weight.
The cost is verbosity. Class names get long. Reviewers occasionally try to “tighten” things with .modal h2 and break the discipline. The fix is a lint rule (Stylelint’s selector-max-specificity set to 0,1,0) that fails CI on any selector heavier than a single class. With that guardrail in place, BEM holds for years. Without it, even disciplined teams drift in six months.
This pattern is the most boring of the three, and that’s a feature. It works on every browser that ever existed, requires no build step, and is understood by every developer you’ll ever hire. It is the right answer for the vast majority of codebases that haven’t picked a discipline yet.
Pattern 2: Cascade layers
Cascade layers — @layer, in the spec since 2022 and now supported everywhere outside of dead browsers — let you put rules into named priority buckets that beat each other regardless of selector weight. A rule in a later layer wins over a rule in an earlier layer, even if the earlier rule has a million IDs.
@layer reset, vendor, components, utilities;
@layer reset {
* { margin: 0; padding: 0; }
}
@layer components {
.button { background: #2563eb; color: white; }
}
@layer utilities {
.text-red { color: red; }
}Now .text-red beats .button not because it’s more specific, but because utilities is declared after components in the layer order. You can use the heaviest selector you want inside the reset layer — say, html body main article div.content — and a single-class rule in components still wins. The escalation game is over because the playing field has been replaced.
The trap with layers is that unlayered rules still beat layered ones. Any legacy stylesheet that doesn’t opt in continues to win against your carefully-tuned layered code. The migration path is “wrap everything you don’t own in @layer vendor { … }” and chase down unlayered fragments one at a time. It’s a one-time cost, and it’s worth paying.
Pattern 3: Zero-specificity wrappers with :where()
The :where() pseudo-class takes any selector and gives it a specificity of zero. That sounds useless until you realize what it lets you do: write rules that can be overridden by anything, including a single class.
/* Specificity: 0,0,0 — anything else wins */
:where(html body main article h1) {
font-size: 2rem;
}
/* Specificity: 0,1,0 — beats the above easily */
.hero-title {
font-size: 3rem;
}This is the right tool for shipping defaults. A component library that wraps its base styles in :where() can be consumed by an application that uses any naming convention, any specificity model, any preprocessor — and the application’s styles will always win without needing !important. The reverse pattern, :is(), takes the specificity of its most specific argument, which is occasionally what you want for grouping but rarely the right default.
Pick one and commit
The patterns are not mutually exclusive — a serious design system uses all three. A reset layer in @layer, BEM-named components inside the components layer, and a third-party widget library that ships its defaults in :where() wrappers is a setup that survives years of feature work without an !important ever appearing.
What kills CSS codebases isn’t any individual pattern. It’s the absence of any commitment to a model, which means every developer falls back to the only thing they can rely on — out-specificity-ing whatever was there before. Pick one of the three above, write down the rule, lint for violations, and the war ends.
Cover photo via Pexels.