Issue №131 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
frontend · 5 min read
frontendJune 18, 2026 · 5 min

I built a CLI that prints a specificity heat map for your CSS

A tiny CLI that reads a stylesheet and prints a color-coded heat map of every selector's specificity — making the usually-invisible cause of failing CSS overrides visible at a glance.

Featured image — css-specificity-cli
01
frontend

Every CSS codebase I've worked on has a haunted selector. The one where you change the color, nothing happens, you add !important, and you move on with a small new debt in your soul. The cause is almost always specificity — and the maddening thing about specificity is that it's invisible. It doesn't show up in your editor. It doesn't show up in the rendered page. It only shows up the moment an override silently fails.

So I built a small tool to make it visible: css-specificity, a CLI that reads a stylesheet and prints a color-coded heat map of every selector's specificity.

What it does

Point it at a file:

css-specificity styles.css
styles.css — 8 selectors  █  1,2,3  #app .sidebar ul li a:hover  L5  █  1,2,1  nav#primary .menu-item.is-active  L6  ▓  0,2,0  .card .title  L4  ▒  0,1,0  .card  L3  ░  0,0,1  body  L2  max specificity  1,2,3

Each row is one selector: a heat glyph from (hot) to · (cool), the (a, b, c) specificity triple, the selector, and the source line. It's sorted most-specific-first, so the selectors most likely to bite you float to the top in red. The whole thing reads like a thermal camera pointed at your stylesheet.

The flag I actually use most is --threshold:

css-specificity styles.css --threshold 0,3,0

That turns it into a budget gate. If any selector is more specific than "three classes," it prints a red and exits 1 — so a stray #id chain fails CI instead of quietly landing in main and starting the next !important war. There's --json for piping into jq or diffing two runs, and --top for when you only want the worst offenders.

How I built it: specificity is a tuple, not a number

The fun part — and the part most "specificity calculator" snippets get subtly wrong — is that specificity isn't a single number. It's a three-part tuple (a, b, c): IDs, then classes/attributes/pseudo-classes, then types/pseudo-elements. And it's compared lexicographically. A single ID (1,0,0) beats nine classes and nine elements (0,9,9), full stop. If you flatten it into a*100 + b*10 + c, you'll get the right answer right up until someone writes a selector with eleven classes, and then you'll be wrong in exactly the situation where being right matters.

So the core comparison keeps the tuple intact:

function compareSpecificity(x, y) {  for (let k = 0; k < 3; k++) {    if (x[k] !== y[k]) return x[k] > y[k] ? 1 : -1;  }  return 0;}

The other place that bites people is the functional pseudo-classes. :where() contributes zero specificity, no matter what's inside it — that's the whole point of it. But :is(), :not(), and :has() take the specificity of their most specific argument. So :is(#a, .b) scores 1,0,0, because the #a branch wins. My tokenizer handles these by recursing into the argument list, computing each branch, and keeping the max by tuple comparison — reusing the exact function above, so the "ID beats nine classes" rule holds inside :is() too.

For parsing the file, I didn't hand-roll a CSS parser — that way lies madness and a thousand edge cases with comments and nested at-rules. I leaned on PostCSS to turn the stylesheet into an AST, walked the rules, and ran my own tokenizer only on the individual selectors. That split kept the gnarly real-world-CSS handling (nested @media, weird whitespace, @keyframes steps that aren't really selectors) in a battle-tested dependency, while my code stayed small enough to test exhaustively.

Which is the last decision worth mentioning: every side effect — stdout, reading the file, the environment — is injected into the run() function rather than reached for directly. The binary is a five-line shim that wires in the real process; the tests wire in arrays and a fake filesystem. That's the trick that let the whole thing hit 100% test coverage without ever touching a real terminal, including the error paths like "file not found" and "that's not valid CSS."

Try it

pnpm add -g css-specificity-cli# or, without installing:pnpm dlx css-specificity-cli styles.css

It needs Node 18+, and the specificity engine is exported as a typed module too, if you want computeSpecificity('#nav .item:hover a') in your own scripts.

What's next

A couple of things I want to add: a --diff mode that compares two runs and shows which selectors got hotter, and proper handling for native CSS nesting (right now nested & selectors are approximated rather than fully resolved). If you find a selector it scores wrong, that's the most useful possible bug report — open an issue with the selector and the score you expected.

Specificity will probably always be a little bit cursed. But at least now I can see it coming.


End of essay

About the author
Er An Khoo