DEV Community

SEN LLC
SEN LLC

Posted on

Building a CSS Specificity Calculator — Counting (a,b,c) and the :is() / :where() Trap Most Implementations Fall Into

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 :name itself 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

Screenshot

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;
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 as a.b; the :is adds 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");
});
Enter fullscreen mode Exit fullscreen mode

: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)) };
}
Enter fullscreen mode Exit fullscreen mode
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
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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 :name adds 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 !important are outside specificity, so they're out of scope.

This is OSS portfolio entry #273 from SEN LLC. https://sen.ltd/portfolio/

Top comments (0)