How is the specificity of a native CSS nested rule computed, and why can a nested rule be more specific than the selector it appears to write?
answer
- think :is(), not string expansion
- the parent list, not the matching branch
- one ID poisons the whole block
- implied & counts the same as written &
- :where() zeroes a heavy branch
basics
~20 sA nested rule's & carries the specificity of the most specific complex selector in the parent rule's selector list, exactly like :is(). One ID anywhere in that list makes every rule nested inside it ID-specific.
solid answer
~50 sThe nesting selector is specified to behave like `:is()`: its specificity equals the highest specificity among the complex selectors in the parent rule's selector list, not the specificity of the branch that actually matched. So `#hero, .card { & p { color: red; } }` matches the same elements as `#hero p, .card p`, but every match — including the one inside `.card` — scores (1,0,1) rather than (0,1,1). The same applies to a rule with no explicit `&`, because the implied `&` is still there. Practically this means a heterogeneous selector list poisons everything nested beneath it, and overrides you expected to win quietly lose. If you need to keep the score down, take the ID out of the parent list or wrap it in `:where()`, which contributes zero. Note also that each occurrence of `&` contributes that specificity, so `& + &` scores twice.
code
css · 12 lines/* Heterogeneous parent list: everything nested scores like an ID */
#hero, .card {
& p { color: red; } /* :is(#hero, .card) p -> (1,0,1) */
}
/* Later, more obvious rule now loses inside .card */
.card p { color: blue; } /* (0,1,1) */
/* Neutralise the heavy branch */
:where(#hero), .card {
& p { color: red; } /* -> (0,1,1); source order now decides */
}go deeper
Recall that a nested rule is not automatically cheap: the parent selector still counts, and an ID in the parent makes the nested rule hard to override.
Explain the mechanism precisely — the nesting selector scores like :is(), taking the highest specificity in the parent list — and compute a triple on the spot for a mixed list.
Demonstrate diagnosis: when a nested override loses, flatten it to its :is() form, compare triples, and decide between restructuring the parent list or wrapping a branch in :where().
Own the systemic angle: heterogeneous selector lists at the top of nested blocks are a specificity hazard that scales badly, so decide whether the codebase permits IDs in selector lists at all and how nesting depth is bounded.
## The rule Specificity is a triple — (ID, class, type) — compared left to right. For nesting, the specification says the nesting selector's specificity is the **largest specificity among the complex selectors in the parent style rule's selector list**. That is precisely the `:is()` rule, and it is easiest to reason about by mentally rewriting a nested rule as `:is(<parent list>) <rest>`. ```css #hero, .card { & p { color: red; } } /* matches like: #hero p, .card p scores like: :is(#hero, .card) p -> (1,0,1) */ ``` Both matched paragraphs — the one inside `#hero` and the one inside `.card` — carry (1,0,1). The score does not depend on which branch matched; it is computed statically from the selector. ## Why this surprises people Sass users have the opposite intuition. A preprocessor expands the same source into two independent selectors, `#hero p` and `.card p`, each with its own specificity: (1,0,1) and (0,1,1). A later `.card p { color: blue; }` rule beats the Sass output inside `.card` — same specificity, later source order — but loses against the native version, which is a full ID higher. Converting a stylesheet from Sass to native nesting can therefore change which rule wins without changing a single selector's text. The implied nesting selector counts too. There is no difference in specificity between: ```css #hero, .card { & p { … } } #hero, .card { p { … } } ``` Both are `:is(#hero, .card) p`. Omitting `&` does not make the parent free. ## Homogeneous lists are fine The hazard is a *heterogeneous* list. If every complex selector in the parent list has the same specificity, `:is()` semantics change nothing: ```css .card, .panel { & .title { font-weight: 700; } /* (0,2,0) either way */ } ``` The cost appears the moment one branch is heavier — an ID, a long compound, or an `!important`-adjacent hack — because every nested rule inherits that ceiling. ## Turning the score down Because `:where()` always contributes zero specificity, you can neutralise a heavy branch in the parent list: ```css :where(#hero), .card { & p { color: red; } /* now :is(:where(#hero), .card) p -> (0,1,1) */ } ``` The rule still matches inside `#hero`; it just no longer scores like an ID. The other options are structural: split the heavy branch into its own top-level rule, or drop the ID selector in favour of a class. ## Repeating & `&` is an ordinary simple selector, so writing it twice contributes twice: ```css .btn { & + & { margin-inline-start: .5rem; } /* .btn + .btn -> (0,2,0) */ } ``` That is usually what you want, but it is worth noticing when you wonder why a `& &`-style rule beats a hand-written single-class rule. ## Depth adds type and class weight, not magic Each nesting level appends its own simple selectors to the flattened result, so three levels of class nesting produce a (0,3,0) selector. Nesting does not *inflate* specificity by itself — the flattened selector is exactly what you would have typed — but it hides the total from you, because no single line of source shows the whole selector. This is the mechanical reason "keep nesting shallow" is repeated so often: the depth you can no longer see is the depth you will fight when overriding. ## How to check When a nested override mysteriously loses, flatten the rule by hand: replace `&` with `:is(` + the parent list + `)`, count the triple, and compare against the competing declaration. Browser devtools show the winning rule and strike through the loser, but they display the nested source, so doing the `:is()` substitution mentally is what tells you *why*. ## What it does not change Nesting only affects specificity through `&`. Cascade origin, importance, and layer order are all decided before specificity is consulted, and nesting does not participate in any of them — a nested rule in an earlier cascade layer still loses to a later layer no matter how specific it is.
- Does omitting the `&` in a nested rule reduce its specificity?No. A nested selector with no `&` is absolutized by inserting an implied `&` plus a descendant combinator, and that implied nesting selector carries exactly the same specificity as a written one. `.card, #hero { p { } }` and `.card, #hero { & p { } }` are identical in both matching and score.
- If the parent list is `#hero, .card`, does a paragraph inside `.card` really score as an ID?Yes. Specificity is computed from the selector, not from which branch matched at runtime, so every element the rule matches carries (1,0,1). This is the whole practical difference from preprocessor output, where the two branches are emitted as separate selectors with separate scores.
- How would you keep a nested block from inheriting an ID's weight?Wrap the ID branch in `:where()`, which always contributes zero, so the parent list's ceiling drops to the next-heaviest branch. Alternatively, move the ID branch out into its own top-level rule, or replace the ID selector with a class — the structural fixes are more honest but the `:where()` wrapper is a one-character-level change.
saying these in an interview costs you the question
- Assuming the matching branch decides the score
- Thinking native nesting compiles to flat Sass-style selectors
- Believing an omitted & is free
- Claiming nesting always increases specificity by itself
- Confusing :is() with :where() when neutralising a branch