A tool that computes the specificity of any CSS selector, breaks it down token by token, and tells you which of several selectors wins. Specificity is a
(a, b, c)triple and looks like "just count IDs, classes, and elements." But the real hinge is how you treat:is()/:not()/:has()/:where()— count the:nameitself and your winner prediction is simply wrong. Fully in-browser.
🌐 Demo: https://sen.ltd/portfolio/css-specificity-calculator/
📦 GitHub: https://github.com/sen-ltd/css-specificity-calculator
What specificity is
When two rules set the same property, specificity decides the winner first (source order only breaks a tie). It's a 3-tuple (a, b, c), compared left to right — a higher bucket beats any amount in a lower one.
| bucket | counts | examples |
|---|---|---|
| a | ID selectors | #header |
| b | class, attribute, pseudo-class |
.btn [type=x] :hover
|
| c | type, pseudo-element |
div ::before
|
// compare two (a,b,c) tuples left-to-right → -1 / 0 / 1
export function compare(x, y) {
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
}
return 0;
}
1,0,0 beats 0,99,99 — one ID outranks ninety-nine classes. There is no carry between buckets. Pin it with a test:
test("compare is lexicographic", () => {
assert.equal(compare([0,1,0], [0,0,9]), 1); // one class > nine types
assert.equal(compare([1,0,0], [0,9,9]), 1); // one id > everything below
});
The universal selector * and all combinators (> + ~ and descendant whitespace) contribute nothing.
The hinge: functional pseudo-classes
Here's the part most hand-rolled calculators get wrong. The weight of :is(), :not() and :has() is not the :name itself — it's the most specific selector inside the argument list. And :where() is always zero, with free arguments:
const MATCHES_ANY = new Set(["is", "not", "has"]); // weight = max of args
const ZERO_PSEUDO = new Set(["where"]); // weight = 0, args free
function contribution(tok) {
// ...
if (tok.type === "pseudo-class") {
const name = tok.name;
if (ZERO_PSEUDO.has(name)) return { value: [0,0,0] }; // :where
if (MATCHES_ANY.has(name)) return { value: maxOfList(tok.args) }; // :is/:not/:has
return { value: [0,1,0] }; // :hover etc.
}
}
maxOfList splits the argument's selector list and recursively computes the max:
function maxOfList(listStr) {
let best = [0,0,0];
for (const sel of splitList(listStr)) {
const { value } = specificityOf(sel); // recurse
if (compare(value, best) > 0) best = value;
}
return best;
}
So:
-
:is(#a, p)→1,0,0(same as#a) -
:where(#a, p)→0,0,0(the id becomes free) -
a:is(.b)→0,1,1(same asa.b; the:isadds nothing of its own)
Tests nail every one, including nesting:
test(":is(#a, p) weighs like #a", () => assert.equal(spec(":is(#a, p)"), "1,0,0"));
test(":where(#a, .b, p) → (0,0,0)", () => assert.equal(spec(":where(#a, .b, p)"), "0,0,0"));
test("the :is() token itself adds nothing", () => {
assert.equal(spec("a:is(.b)"), "0,1,1"); // not (0,2,1)
});
test("nested :is keeps taking the max", () => {
assert.equal(spec(":is(:is(#deep), span)"), "1,0,0");
});
:where() exists precisely so library and reset styles can sit at specificity zero, trivially overridable by consumers. Miss this in your calculator and you overestimate how strong :where-wrapped rules are.
A small :nth-child(... of S) trap
:nth-child() is a pseudo-class, so normally it adds one b. But with the of S form it becomes one b plus the max specificity of S:
const NTH_OF = new Set(["nth-child", "nth-last-child"]);
// ...
if (NTH_OF.has(name) && /\bof\b/i.test(tok.args)) {
const ofPart = tok.args.replace(/^[\s\S]*?\bof\b/i, ""); // text after "of"
return { value: add([0,1,0], maxOfList(ofPart)) };
}
test("plain :nth-child is one pseudo-class", () => {
assert.equal(spec("li:nth-child(2n+1)"), "0,1,1");
});
test("of S adds the selector's specificity", () => {
assert.equal(spec(":nth-child(2 of .foo)"), "0,2,0"); // pseudo-class + .foo
});
The unglamorous part: commas and brackets
A selector list splits on commas — but not the commas inside :is(a, b), and not the comma inside [data-x="a,b,c"]. You have to split on top-level commas only:
export function splitList(selector) {
const out = [];
let depthParen = 0, depthBracket = 0, quote = null, start = 0;
for (let i = 0; i < selector.length; i++) {
const ch = selector[i];
if (quote) { if (ch === quote && selector[i-1] !== "\\") quote = null; continue; }
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "(") depthParen++;
else if (ch === ")") depthParen--;
else if (ch === "[") depthBracket++;
else if (ch === "]") depthBracket--;
else if (ch === "," && depthParen === 0 && depthBracket === 0) {
out.push(selector.slice(start, i).trim());
start = i + 1;
}
}
const last = selector.slice(start).trim();
if (last) out.push(last);
return out.filter(Boolean);
}
test("commas inside :is() stay together", () => {
assert.deepEqual(splitList(":is(a, b), c"), [":is(a, b)", "c"]);
});
test("commas inside [] do not split", () => {
assert.equal(analyze('[data-x="a,b,c"]').length, 1);
});
The tokenizer uses the same idea: it scans [...] and (...) as a single balanced token, respecting quotes and nesting, so a nasty input like a[title="]"] (a close bracket inside the attribute value) doesn't break parsing.
Picking the winner
export function winnerIndex(results) {
let best = 0, tie = false;
for (let i = 1; i < results.length; i++) {
const cmp = compare(results[i].value, results[best].value);
if (cmp > 0) { best = i; tie = false; }
else if (cmp === 0) tie = true;
}
return tie ? -1 : best; // -1 = tie, where CSS would fall back to source order
}
The demo highlights the winner in green and renders each selector as colored chips showing exactly which bucket each token added to.
Architecture
specificity.js — tokenizer, per-token weights, list splitting,
compare + winnerIndex (DOM-free, 42 tests)
app.js — textarea → (a,b,c) chips + token breakdown, live
specificity.js is DOM-free, so all 42 tests run under node --test. Inline styles and !important live outside selector specificity (a separate layer that wins before the comparison even happens), so they're deliberately out of scope.
Try it
- Demo: https://sen.ltd/portfolio/css-specificity-calculator/
- GitHub: https://github.com/sen-ltd/css-specificity-calculator
Paste :is(#x) a and :where(#x) a on two lines and watch two visually identical selectors split into 1,0,1 and 0,0,1 — the clearest way to feel what :where() throws away.
Takeaways
- Specificity is
(a, b, c), compared left to right; a higher bucket wins outright (1,0,0>0,99,99). -
*and combinators are zero. -
The hinge:
:is()/:not()/:has()weigh as the max of their arguments;:where()is always 0. The:nameadds nothing itself. -
:nth-child(... of S)is one pseudo-class plus the max of S. - Split on top-level commas only; scan
[...]/(...)as balanced, quote-aware tokens. - Inline styles and
!importantare outside specificity, so they're out of scope.
This is OSS portfolio entry #273 from SEN LLC. https://sen.ltd/portfolio/

Top comments (0)