How CSS Specificity Decides Which Rule Wins (and Why Your Style Isn't Applying)
A practical guide to CSS specificity: the (id, class, element) weighting, why inline styles and !important override, and how to debug style conflicts fast.
How CSS Specificity Decides Which Rule Wins (and Why Your Style Isn't Applying)
You set color: gray on a button, the browser ignores you, and ten minutes later you're typing !important out of pure frustration. I've been there more times than I'd like to admit. The fix almost never requires the sledgehammer — it requires understanding the one rule that quietly governs every conflict in your stylesheet: specificity.
Specificity is how the browser breaks ties. When two CSS rules both target the same element and set the same property, the browser doesn't pick the one you wrote most recently, or the one that's longest, or the one nearest the bottom of the file (well — not first, anyway). It scores each selector, compares the scores, and the higher score wins. Once you can read that score, "my style isn't applying" stops being a mystery and becomes a thirty-second diagnosis.
The (a, b, c) Score Every Selector Carries
Every selector gets a three-part score, often written as a triple (a, b, c):
- a counts ID selectors (
#header,#main-nav) - b counts classes, attribute selectors, and pseudo-classes (
.btn,[type="text"],:hover,:nth-child()) - c counts element types and pseudo-elements (
div,a,::before,::after)
A few things contribute nothing: the universal selector *, and the combinators >, +, ~, and the descendant space. They change what matches without changing how specific the rule is.
So #sidebar .link a scores (1, 1, 1) — one ID, one class, one element. And nav ul li a scores (0, 0, 4) — four element names, nothing else.
The One Rule That Trips Everyone Up
Here is the single most misunderstood point, and it's worth tattooing on your monitor: specificity is compared column by column, left to right — and a higher column always beats a lower one, regardless of count.
The browser looks at a first. If one selector has a higher a, it wins, full stop. It never even reads b or c. Only when a ties does the browser move to b, and only when b ties does it look at c. There is no carrying between columns. A big b can never "add up" into the a column.
This means a single ID beats any number of classes:
#xscores(1, 0, 0).a.b.c.d…chained 256 times scores(0, 256, 0)
The ID wins. The a column is 1 versus 0, that comparison settles it, and the 256 classes are never consulted. The old folklore that "256 classes overflow into an ID" was true in exactly one ancient browser build and has been wrong in every engine you'll ship to today. No modern engine carries.
If you want to confirm any of this without trusting your own arithmetic, paste the selectors into the CSS Specificity Calculator — it scores each one as an (a, b, c) triple and tells you which column decided the match.
A Worked Example: Two Selectors, One Winner
Say a teammate wrote this:
#sidebar .link { color: red; }
and you later added:
.link--muted { color: gray; }
Your muted gray refuses to show up. Score them:
#sidebar .link→ one ID, one class →(1, 1, 0).link--muted→ one class →(0, 1, 0)
Compare the a column first: 1 versus 0. The first rule wins immediately. The browser never looks at the b columns (which happen to tie at 1). Source order is irrelevant here — even though your rule comes later in the file, it loses because its a column is lower.
Now the fix is obvious, and there are two clean options. You can raise your rule's score to match or exceed (1, 1, 0), or — the better move — drop the other rule's weight by wrapping its ID in :where(). Rewriting the original as :where(#sidebar) .link re-scores it to (0, 1, 0), because everything inside :where() contributes zero. Now both rules tie at (0, 1, 0), source order takes over, and your later .link--muted wins cleanly. No !important required.
For the record: :is() and :not() behave differently from :where(). They take the specificity of their most specific argument. So :is(#sidebar, .panel) .link scores (1, 1, 0) because #sidebar is the heaviest member, while :where(#sidebar) .link scores (0, 1, 0).
Why Inline Styles and !important Sit Outside the Triple
Two things live above the (a, b, c) game entirely, which is exactly why they feel like cheating.
Inline styles — the style="color: gray" attribute written directly on an element — aren't part of the selector triple at all. They sit one level higher, often notated as a fourth column: (1, 0, 0, 0). An inline style beats any selector you can write in a stylesheet, including a contrived #id#id#id. So when a rule "isn't applying" and the math says it should win, check the element in dev tools for an inline style attribute first. That's the most common silent override.
!important jumps out of the normal comparison altogether. An !important declaration beats any non-important declaration regardless of the triple — it even overrides an inline style. It is genuinely the strongest move available to you in a stylesheet, which is precisely why it's dangerous. The moment you add one, the next person who needs to override your rule has to add their own !important, and you've started an arms race.
When two !important declarations compete, the browser falls back to normal rules: first specificity, then source order. So a later, more specific !important still beats an earlier one. Treat !important as a deliberate utility-override sledgehammer, not a default — and before you reach for it, confirm you actually need it.
Debugging Style Conflicts Without Guessing
Here's the workflow I use when a style won't take, in order:
- Check for an inline
styleattribute on the element. It silently beats every stylesheet selector. This catches a surprising share of "impossible" bugs. - Score the competing selectors. Find the rule that's currently winning and your rule, and compare their triples column by column. If your
ais lower, no amount ofborcwill save you. - If you're tied, source order decides — the rule defined later wins. Moving your rule down the file (or down the import order) fixes it without changing specificity at all.
- If you're losing, raise your score with intent (add a meaningful class) or lower theirs with
:where(). Reaching for!importantshould be the last resort, reserved for cases where you've confirmed the losing triple genuinely can't be raised.
The trap to avoid: stacking selectors blindly until something sticks. That works once and leaves a (2, 3, 1) monster that the next bug fixes by stacking even higher. Scoring deliberately keeps your specificity flat and your overrides predictable.
A couple of gotchas worth memorizing while you're here. Pseudo-elements like ::before and ::after add to the c (element) column, not b — the double colon is the tell, even though it starts with a colon like a pseudo-class. And combinators (>, +, ~, the descendant space) add nothing, so div > p + span is just (0, 0, 3).
Keep Specificity Low and Legible
The healthiest stylesheets keep most rules at (0, 1, 0) — a single class — so any later class can override any earlier one and source order stays meaningful. Heavy IDs and deeply nested selectors are future support tickets: every (1, 2, 0) rule is something a consumer of your CSS can't override without copying your weight or escalating to !important.
When you're cleaning up a stylesheet, it helps to run it through a tidy pass first so the selectors are easy to read — the CSS Formatter normalizes spacing and structure so you can actually see what each rule targets before you score it. Then paste the selectors that are fighting into the specificity calculator, read which column decided the winner, and fix the score, not the symptom. Once you're scoring instead of guessing, the cascade stops being adversarial and starts being a system you can reason about.
Made by Toolora · Updated 2026-06-13