Specificity & the Cascade
Building a Web Browser Engine: Parsing & the DOM
Chapter 6 · Specificity & the Cascade
Chapter 5 answered "does this one selector match this one element" in isolation. A real stylesheet has dozens of rules, and a single element routinely matches several of them at once — sometimes agreeing, sometimes flatly contradicting each other. The cascade is the algorithm that decides, for every property an element might have styled, which one of the competing values actually wins.
Specificity: a Tuple, Not a Single Number
The tuple is compared lexicographically — Python's own default tuple comparison — meaning the first component that differs decides the whole comparison. Nothing here ever collapses the tuple into one combined number.
specificity('#main') is (1, 0, 0). specificity('.a.b.c.d.e.f.g.h.i.j') — ten classes on one element — is (0, 10, 0). Compared as tuples, (1, 0, 0) > (0, 10, 0) is True: the id wins outright, no matter how many classes are stacked against it. If specificity were computed as a single summed number instead — the mistake newcomers to CSS make constantly — ten classes would very plausibly outscore one id.
specificity('.a.b') is (0, 2, 0), genuinely higher than specificity('.a')'s (0, 1, 0). And a descendant selector like div.card p sums across every part, not just the target — specificity('div.card p') comes out to (0, 1, 2): one class from .card, two tags from div and p combined.
The Cascade: Merging Per Property, Not Per Rule
Sorting weakest-to-strongest and then letting each matching rule's declarations overwrite the running result dict, one property at a time, is what makes this per-property rather than per-rule. A rule that loses the cascade on one property can still be the only rule that sets some other property at all — and that other value has to survive.
.highlight { color: blue; font-size: 12px; } is written first. An id rule #main { color: red; } is written second, on the same element. The id rule's higher specificity wins the color conflict — final color is red — but font-size was never contested at all, and survives from the class rule untouched: {'color': 'red', 'font-size': '12px'}.
{'color': 'red'} for the exact same input, with font-size gone entirely. The correct, per-property merge keeps it. This is exactly the kind of bug that would only show up on a real page where two rules divide responsibility for different properties on the same element — extremely common in practice.
p rules — identical specificity, (0,0,1) each — setting color: green and color: purple respectively. With green first and purple second in the stylesheet, the final color is purple. Swapping which one appears later flips the result to green. Source order is only ever consulted once specificity is exactly tied — it never overrides a genuine specificity difference.
A Real Bug: Comma-Separated Selectors in One Rule
Chapter 4's own Rule.selectors is a list — one rule like h1, .warning { color: blue; } produces a single Rule whose selectors field is ['h1', '.warning'], sharing one set of declarations. The cascade() above loops over that list and breaks the moment any one of them matches — which quietly picks whichever selector happens to be listed first, not necessarily the one with the highest specificity.
<h1 class="warning">. One rule, ['h1', '.warning'] { color: blue; }, written first. A second rule, plain h1 { color: red; }, written after it. Because 'h1' is listed first in the comma rule and matches immediately, cascade() records its specificity as (0,0,1) — the tag-only figure — even though the same rule also matches via .warning, specificity (0,1,0), genuinely higher. That tuple tie against the second rule's own (0,0,1) gets broken by source order, and the later rule wins: red. Real CSS treats a comma list as though each selector were its own separate rule sharing the same declarations — the first rule should win outright on its .warning specificity alone, blue, regardless of source order.
The fix: for each rule, check every one of its own selectors that matches, and keep the highest specificity among them — not just the first one found.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Specificity as a compared tuple, never a summed number | Chapter 5's own compound-selector matching — the same "every part has to hold together" discipline, applied here to scoring rather than matching |
| Per-property merging across every matching rule | Chapter 8's own style tree, which needs a complete, final property set for every DOM node — exactly what cascade() produces for one element at a time |
| A comma-list rule needing its own highest-matching specificity | Chapter 5's own honest id-uniqueness gap — another case where a real, unglamorous edge case in how CSS selectors combine only surfaces once you actually test it, not when you reason about the "normal" case alone |
Hands-On Exercises
Build one element that matches three rules at once through a tag selector, a class selector, and an id selector — all setting the same property. Run them through cascade() once with the id rule written last in the stylesheet, and again with the id rule written first. Confirm the id rule wins both times, and explain why source order never gets a chance to matter here.
Compute specificity() for the descendant selector "div.card p" and for the plain id selector "#x". Compare the two tuples directly and confirm which one wins, even though the first selector combines a tag, a class, and a second tag across two separate parts of the selector.
Reproduce this chapter's own comma-selector bug directly: an <h1 class="warning">, a rule ['h1', '.warning'] declaring blue written first, and a plain h1 rule declaring red written second. Confirm the buggy cascade() above really does return red. Then write a fixed version that computes, for each rule, the highest specificity among every one of its own selectors that matches — and confirm it now correctly returns blue.
Chapter 6 Quick Reference
- Specificity: an
(id_count, class_count, tag_count)tuple, compared lexicographically — never summed into a single score - Verified: one id always outranks any number of classes; two classes always outrank one, regardless of source order
- Cascade: merges declarations per property across every matching rule — a rule that loses on one property can still be the only source of another
- Verified: a naive "winning rule replaces everything" shortcut silently drops properties a losing rule was the only source for
- Tie-break: source order only ever matters once specificity is genuinely equal — later rule wins a true tie, but never overrides a real specificity difference
- Real bug found and fixed: a comma-separated selector list inside one rule needs its highest-matching specificity, not just whichever selector happens to be listed first
- Next chapter: Inheritance & Computed Values — what happens to a property no rule ever set at all