DEV Community

Cover image for The CSS Selector Built to Lose Every Fight
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

The CSS Selector Built to Lose Every Fight

You add one class to override a button's background. It doesn't work.

You check the class name — correct. You check the CSS is loading — it is, and it comes after the component's stylesheet in the build output. Cascade order should mean you win. You don't.

So you do what everyone does at this point: you slap !important on it. It works. Six weeks later, someone on your team can't override your override, so they reach for !important too. Now there are two !important declarations fighting over one button, and the person who ships last wins by accident, not by design.

That's not a cascade. That's an arms race. And there's a selector in CSS built specifically to end it — by promising to lose.

The obvious fix that isn't

Here's the setup. A shared component library ships a card with a button inside it:

.card .button {
  background: crimson;
}
Enter fullscreen mode Exit fullscreen mode

You're the consumer. You write:

.button {
  background: teal;
}
Enter fullscreen mode Exit fullscreen mode

Yours loads after theirs. By the "last one wins" rule everyone half-remembers, teal should win. It doesn't, because that rule only applies when specificity is tied. It isn't tied here.

.card .button is two class selectors: specificity 0-2-0. Your lone .button is 0-1-0. Specificity beats source order every time, so the library's rule wins no matter which file loads last. This is the part people skip when they learn CSS: source order is the tiebreaker, not the first rule.

Quick check before you scroll — what's the specificity of .card .button.button.button? (Stacking a class three times is a real trick people use to win fights.) It's 0-4-0 — each repetition of a class counts. That's the arms race in miniature: two engineers, each adding classes to their own selector until one of them gives up and reaches for !important.

Why nesting makes it worse

Component libraries nest selectors constantly, because that's how you scope a style to "inside this component" without a build step rewriting every class name. But every level of nesting adds specificity whether you meant it to or not.

.dashboard .card .card-header .button {
  background: crimson;
}
Enter fullscreen mode Exit fullscreen mode

Four classes deep, 0-4-0, and now your single override needs four classes of its own just to tie — and tying only gets you a coin flip on source order, which you don't control once someone reorders imports in a bundler six months from now.

This is exactly the situation :where() was added to solve.

The selector that always loses

:where() takes a selector list, same as :is() or a plain grouped selector — but whatever's inside it contributes zero specificity to the rule. Not "low" specificity. Zero. Always.

:where(.dashboard .card .card-header) .button {
  background: crimson;
}
Enter fullscreen mode Exit fullscreen mode

Wrap those four nested classes in :where(), and the whole thing collapses to the specificity of .button alone: 0-1-0. It doesn't matter if you wrap ten IDs in there — :where(#a #b #c) is still 0-0-0. The spec is blunt about this: :where()'s specificity is always replaced with zero, full stop, regardless of what's inside it.

Now your one-class override is a real tie, not a losing fight:

.button {
  background: teal;
} /* ties :where()'d 0-1-0 → source order decides → yours loads last → teal wins */
Enter fullscreen mode Exit fullscreen mode

No !important. No stacking four classes to match a wall you didn't build. The library author gave you a door instead of a wall, on purpose.

Go try it — the demo below has the actual browser cascade wired up, not a simulation. Flip the toggle and watch the winning color change in real time.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The gotcha: :is() does the opposite

Here's where people get burned the first time. :is() looks like :where()'s twin — same job, group a selector list into one — but it does not zero out specificity. :is() takes on the specificity of its most specific argument.

:is(#sidebar, .card) .button {
  background: crimson;
}
Enter fullscreen mode Exit fullscreen mode

You'd think this is "cheap" because .card is right there and low-specificity. It isn't. Because #sidebar is also a valid match inside the :is(), the whole rule gets ID-level specificity: 1-0-0. Every .button matched through the .card branch still inherits the weight of the #sidebar branch, even when #sidebar never matches at all on that element.

So if you're wrapping selectors purely to group them and save keystrokes, :is() is the more compact option. If you're wrapping them to flatten specificity so consumers can override you, :is() won't do it — you need :where(). They read as interchangeable. They're not.

When you actually want !important

None of this makes !important evil. Utility classes that need to win unconditionally — a .hidden { display: none !important; } toggled by JS, or a print stylesheet override — are a legitimate, narrow use case, because you're declaring "this always wins" as a deliberate rule, not discovering it as an accident three specificity levels deep. The problem was never the escape hatch. It's using the escape hatch as your default plan for cascade order because nobody scoped the selectors to lose gracefully in the first place.

The rule to keep

If you write component or reset CSS that other people's code needs to override, wrap your selector scoping in :where() and let the actual styling class carry the specificity. Consumers get a fair fight with a single class, source order works the way people assume it already does, and nobody has to escalate.

Next time you catch yourself typing !important, ask first whether the rule you're fighting was ever meant to lose. What's the ugliest specificity war you've shipped — three classes deep, or straight to !important on line one? Tell me in the comments.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


Thanks for reading! Let's stay connected:

Top comments (0)