Every time two CSS rules fight over the same element and the "wrong" one wins, the culprit is specificity — and most of us carry a fuzzy mental model of it. So I built a real specificity calculator: type a selector, watch it tokenize into the (a, b, c) triple, then pit two selectors against each other and see which wins and exactly why. It sounds like it needs a full CSS parser. It doesn't. Specificity is just three counters, and the whole engine is a small hand-written tokenizer — maybe 60 lines of honest code, no library, no regex hacks that break on [href="a>b"].
Three counters, compared column by column
The entire "score" of a selector is a triple {a, b, c}: a counts #id selectors, b counts classes, [attributes] and :pseudo-classes, and c counts elements and ::pseudo-elements. The universal * and every combinator (> + ~ and the descendant space) count nothing. The step everyone gets wrong is the comparison — the triple is not a base-10 number. You compare a first; only if the a's tie do you look at b; only then c:
function cmp(x, y){
if (x.a !== y.a) return x.a > y.a ? 1 : -1; // IDs decide first
if (x.b !== y.b) return x.b > y.b ? 1 : -1; // then classes/attrs/pseudo-classes
if (x.c !== y.c) return x.c > y.c ? 1 : -1; // then elements
return 0; // exact tie -> source order wins
}
That's why (0,1,0) beats (0,0,11) — one class outranks eleven elements — and why one #id outranks a wall of classes. A bigger later column can never overtake a smaller earlier one.
Walk the selector, don't regex it
A regex like /\.[\w-]+/g looks tempting until an attribute value contains a dot or a bracket and it falls apart. A tiny scanner is both simpler and correct: look at the first character of each token to know its kind, then consume to the end of that token. # starts an ID (bucket a), . a class (bucket b), a bare identifier a type selector (bucket c). Attribute selectors [...] count as b, but you must read to the matching ] while respecting quotes so [href="a]b"] doesn't fool you. The universal * and the combinators are real tokens that contribute zero — I record them so the breakdown can show them, but they bump no counter.
Pseudo-classes vs pseudo-elements — the colon count
A single colon is a pseudo-class (:hover, :first-child) → b. A double colon is a pseudo-element (::before) → c. The catch is four legacy names — :before, :after, :first-line, :first-letter — that are pseudo-elements even with one colon, so they special-case to c:
const LEGACY_PE = ['before','after','first-line','first-letter'];
if (dbl || LEGACY_PE.includes(name)) out.push({text, bucket:'c'}); // ::el -> c
else if (name === 'where') out.push({text, bucket:'0'}); // :where() -> 0
else if (['is','not','has'].includes(name))
out.push({text, bucket:'fn', add: mostSpecific(arg)}); // recurse
else out.push({text, bucket:'b'}); // :hover, :nth-child ...
The genuinely clever bit: :is(), :not(), :has() recurse
These three are "transparent" — the pseudo-class adds nothing of its own; it contributes the specificity of its most specific argument. So :is(#a, .b) is worth (1,0,0), the #a. I split the argument on top-level commas, score each part with the very same engine, and keep the max — which is why the tokenizer has to expose a callable score() it can call on itself. :where() is the lone exception: it is always zero, no matter what's inside it. Getting this recursion right is the difference between a toy and something you'd trust.
Above the game: inline styles and !important
Specificity only decides among normal, selector-based declarations. Two rungs sit above it. An inline style="" attribute behaves like a fourth, higher column — think (1,0,0,0) — beating any selector. And !important lifts a declaration out of the normal cascade entirely: a low-specificity !important rule still wins. So the real precedence is !important → inline → specificity (a,b,c) → source order, and when two selectors compute to the exact same triple, the one written last in the CSS wins. The lesson underneath all of it: when your CSS "won't apply," you now have a mechanical way to prove which rule is winning and why — instead of shotgunning !important until something sticks.
Type a selector and watch it tokenize, or load a matchup and see who wins:
https://dev48v.infy.uk/solve/day45-css-specificity-calculator.html
Top comments (0)