You write .sidebar .card { color: blue; }, load the page, and the card is still red from a rule you added an hour earlier: .card { color: red; }. Your new rule comes later in the file. It still loses. The browser compares the two selectors with a fixed point system, and .sidebar .card scores higher than .card no matter which one you wrote last.
That point system is specificity. Once you can calculate it on sight, “why isn’t my CSS applying” becomes arithmetic. It has nothing to do with layout: Grid or Flexbox decides where an element sits, and specificity decides which rule sets its color or size when two rules disagree.
How the browser scores a selector
Every selector gets a weight made of three numbers, usually written as a dash-separated triple: ID-class-type. Add up what the selector contains, column by column:
| Column | What counts | Weight per match |
|---|---|---|
| ID | #header | 1-0-0 |
| Class / attribute / pseudo-class | .card, [type="radio"], :hover, :nth-child(2) | 0-1-0 |
| Type / pseudo-element | div, h1, ::before | 0-0-1 |
The universal selector * and the combinators (the space, >, ~, +) add 0-0-0. They decide which elements match, not how strongly the rule wins.
Some real selectors:
| |
To compare two values, read them left to right and stop at the first column that differs. 1-1-0 beats 0-2-0 because the ID column (1 vs 0) decides before anyone looks at classes. So #main .card beats .sidebar .card .title .text, however long the second selector gets. One ID outranks any number of classes.
Why a later CSS rule can still lose
Back to the opening example, with the numbers filled in:
| |
0-2-0 beats 0-1-0 in the class column, so the card is blue regardless of source order. Source order is the tiebreaker most people reach for first, but it only applies when the triples are identical:
| |
Both rules score 0-1-0, so the one later in the stylesheet wins (or in the later <style> block, or the stylesheet linked after the other). That’s the only case where “last one wins” is the right mental model. It works in small projects, which is why everyone learns it first, and it stops working once a project has more than a handful of selectors.
Inline styles sit above all of this. A style="color: green" attribute on the element overrides every rule in every stylesheet, however many IDs they chain. Only !important declarations can beat it.
| |
| |
!important isn’t part of the specificity triple. It’s a separate, higher-priority bucket: any !important declaration beats any normal declaration, including inline styles, regardless of selector weight. When two !important declarations collide, specificity (then source order) decides between them, as it would without the flag. This is how one !important turns into an arms race. The next developer who needs to override it has no move left except another !important, and soon nobody can tell which rule is meant to win.
Which selectors add zero specificity: :where() vs :is()
A few selectors let you target precisely without paying the normal price.
:where() always contributes 0-0-0, whatever you put inside it:
| |
That makes :where() the right wrapper for defaults and theme styles that should be easy to override. A component library can ship its defaults inside :where(), and a consumer’s plain .card { } rule beats them with no !important needed.
:is(), :not(), and :has() work differently. They add no weight of their own, but they take the weight of their most specific argument:
| |
So :where() is free, and :is() costs as much as the heaviest thing you put inside it, even when that argument never matches the element.
How cascade layers override specificity
If you’re stacking classes and IDs to win fights that have nothing to do with how specific a selector should be, the project needs @layer, not more nesting.
| |
Layer order comes from where you declare the names, not from selector weight. utilities was declared last, so a rule in it beats a rule in components, even though .text-blue has the same 0-1-0 weight as .card. Specificity still settles conflicts inside a layer. Across layers, layer order wins outright and specificity is never consulted.
One rule catches almost everyone the first time: for normal (non-!important) declarations, styles outside any layer beat styles inside a layer. Move your component CSS into @layer components but leave one override unlayered, and that unlayered rule wins whatever order you declared the layers in. For !important declarations the precedence flips: the first-declared layer’s important rules beat later ones, and unlayered important styles rank lowest of all. The inversion is deliberate, and it’s the part of the spec to re-read before you depend on it.
Browser support stopped being an argument against layers a while ago: @layer has shipped in every major engine since 2022. The real cost is organizational. The team has to agree on a layer order up front (reset, base, components, utilities is a common one) and file new CSS into the right layer. That’s a process problem more than a syntax one.
Common CSS specificity mistakes
- Fighting specificity with more specificity. Each override gets one class or ID heavier than the last, until changing a color needs
#app .page .section .card.is-active.is-selected. It’s the same failure mode as spaghetti code: every fix adds tangle instead of removing it. The fix is almost never a heavier selector. It’s a flatter one, or a layer. - Reaching for
!importanton the first conflict. It wins the immediate fight and sets up the next one, because the next override has no normal-specificity move available. - Forgetting that pseudo-classes count.
:hover,:focus,:nth-child(), and:not()’s argument all add0-1-0. A plaina { color: blue; }further down the file will not overridea:not(.disabled) { color: red; }above it, because the:not()version is heavier. - Assuming the inherited value wins because the rule “feels” stronger. A rule that targets an element directly always beats an inherited value, whatever the specificity of the rule it was inherited from. If a child shows the wrong color, look for a rule targeting that child before you suspect inheritance.
- Mixing
:is()into a selector and expecting it to stay cheap.:is()takes the weight of its heaviest argument, so:is(.card, #sidebar)is a1-0-0selector in disguise.
How to check specificity in DevTools
You don’t have to count columns by hand when a bug is in front of you. Inspect the element in Chrome or Firefox DevTools and open the Styles panel (Rules in Firefox). Every matching rule is listed in cascade order, with overridden declarations struck through. Hover a selector and both browsers show its specificity. From there you can tell in seconds whether you’re looking at a specificity gap, a layer-order issue, or plain source order between two equal-weight rules.
Next time a rule silently loses, calculate both triples before you add a class or an !important to force it. Usually the fix is to take weight off the selector that’s winning, not to pile more onto the one that should have won.