How CSS parsing & specificity actually work
CSS looks trivial — selector { property: value } — yet parsing it correctly, computing selector specificity the way browsers do, and minifying it without breaking a single rule are all subtler than they look. This is a tour of what's inside a stylesheet and how OmniViewer's dependency-free tools read it, with real JavaScript you can lift.
What CSS is
CSS — Cascading Style Sheets, a W3C standard since 1996 — is a declarative language for describing how a document looks. A stylesheet is a flat list of rules. Each rule pairs a selector list (which elements it targets) with a declaration block (what to change), and the browser resolves conflicts between rules through the cascade: origin, layers, specificity and source order, in that priority.
Structurally there are only a handful of things a parser can meet at the top level: qualified rules (a { … }), at-rules (@media …, @import …;), comments, and — inside modern nested CSS — more rules. That small vocabulary is what makes a hand-written parser tractable.
Tokens & the grammar
Before structure there are tokens. The one rule that trips up naïve parsers is that {, }, ; and : are only structural outside of a few contexts — you must skip over them when they appear inside a string, a comment, a (…) function call or a […] attribute selector. A content: "}" declaration or a url(data:image/svg+xml;base64,…) value must not end the block early.
.card::after {
content: "; not a separator"; /* the ; is inside a string */
background: url(data:image/gif;base64,AAAA); /* and here */
}
So every scanner in the parser — the one that reads a selector, the one that reads a declaration value — shares the same discipline: when it sees a quote it skips to the matching quote; when it sees /* it skips to */; when it sees ( or [ it skips to the balanced close. Only then does it treat { } ; : as grammar.
A recursive-descent parser
With that discipline in place the parser is a short recursive descent. parseNodes() reads a sequence until it hits a closing } (or end-of-input at the top level). At each step it dispatches on the first non-whitespace character:
function parseNodes(top) {
const nodes = [];
for (;;) {
skipWs();
if (i >= n) break; // end of input
const c = text[i];
if (c === '}') { if (!top) i++; break; } // end of this block
if (c === '/' && text[i + 1] === '*') { nodes.push(parseComment()); continue; }
if (c === '@') { nodes.push(parseAtRule()); continue; }
nodes.push(parseRuleOrDeclaration());
// parseRuleOrDeclaration scans a "segment" until { ; or }:
// ends at '{' → it was a selector, recurse into the block → a RULE
// ends at ';' → it was a "prop: value" → a DECLARATION
}
return nodes;
}
The clever part is that you don't know whether you're looking at a selector or a declaration until you see what terminates it. nav a and margin: 0 look alike for a while; if the segment ends at { it was a selector and you recurse, if it ends at ; (or }) it was a declaration. That single lookahead handles at-rules, nested rules and plain declarations with no backtracking.
The output is a small tree of nodes — {type:'rule', selector, body}, {type:'atrule', name, prelude, block}, {type:'declaration', prop, value, important}, {type:'comment'}. Every tab is a walk over that tree: FORMATTED re-serialises it with indentation, MINIFY re-serialises it tight, STATS counts it, and SELECTORS analyses it.
Specificity, for real
Specificity is the number every CSS author half-remembers and regularly gets wrong. It is a triple (a, b, c), compared left-to-right like a version number — a single ID always beats any number of classes:
| Bucket | Counts | Examples |
|---|---|---|
| a | ID selectors | #main |
| b | classes, attributes, pseudo-classes | .active, [type=text], :hover |
| c | type selectors, pseudo-elements | div, ::before |
The universal selector * and combinators (>, +, ~, descendant space) add nothing. Two rules deserve special care:
- Pseudo-elements use two colons (
::before) and count as c; a single-colon pseudo-class (:hover) counts as b. Reading::before:is the whole trick. - The functional pseudo-classes are recursive.
:where()always contributes zero — that's its entire reason to exist.:not(),:is()and:has()contribute the highest specificity found among their comma-separated arguments. So:is(#a, p)contributes(1,0,0), the same as#a.
nav#primary a.current is (1, 1, 2) — one ID, one class, two type selectors. Paste your own stylesheet into the SELECTORS tab and it ranks every selector this way.The specificity code
Here is the calculator, condensed. It walks the selector once, and for the functional pseudo-classes it calls itself on the arguments:
function specificity(sel) {
let a = 0, b = 0, c = 0, i = 0;
while (i < sel.length) {
const ch = sel[i];
if (ch === '#') { a++; i = skipName(sel, i + 1); }
else if (ch === '.') { b++; i = skipName(sel, i + 1); }
else if (ch === '[') { b++; i = matchPair(sel, i, '[', ']'); }
else if (ch === ':' && sel[i + 1] === ':') { c++; i = skipName(sel, i + 2); } // ::pseudo-element
else if (ch === ':') {
const name = readName(sel, i + 1); // the pseudo-class name
i += 1 + name.length;
if (sel[i] === '(') { // functional: :not() :is() …
const arg = insideParens(sel, i);
i = matchPair(sel, i, '(', ')');
if (name === 'where') { /* contributes 0 */ }
else if (name === 'not' || name === 'is' || name === 'has') {
let best = [0, 0, 0]; // take the max over arguments
for (const part of splitTopLevel(arg, ',')) {
const s = specificity(part);
if (cmp(s, best) > 0) best = s;
}
a += best[0]; b += best[1]; c += best[2];
} else b++; // :nth-child(…) etc.
} else b++; // plain :hover, :focus …
}
else if (ch === '*') i++; // universal: nothing
else if (isNameStart(ch)) { c++; i = skipName(sel, i); } // type selector
else i++; // combinator / whitespace
}
return [a, b, c];
}
That's the entire algorithm the browser uses, in about thirty lines — no dependencies, no DOM. It runs identically in a Web Worker and in a Node test.
Minifying safely
Minification is just re-serialising the parsed tree with no insignificant whitespace: .a{color:red} instead of a formatted block. The traps are all about what is significant:
- Whitespace inside
calc()is load-bearing.calc(100% - 10px)is valid;calc(100%-10px)is not. So a value collapses runs of whitespace to a single space but never removes the spaces around operators. - Commas are always safe to tighten.
rgb(1, 2, 3)→rgb(1,2,3),a, b→a,b— outside of strings. - Combinators tighten too.
a > b→a>b, but a descendant space (a b) must stay. - A trailing semicolon before
}is redundant and dropped; a leading0stays because that's a correctness call, not a size one. - A
/*! … */comment is kept — the convention for a license banner — while every other comment goes.
Because it works from the parse tree, the minifier can never emit a broken rule: it only knows how to print rules, declarations and at-rules, so malformed input is dropped rather than corrupted.
Scaling to a huge file
Generated stylesheets — atomic-CSS frameworks, bundled design systems — can run to many megabytes. OmniViewer keeps two promises at once. The RAW and HEX views are windowed: they read only the bytes needed to paint the screen, so they open a file of any size instantly. The analysis tabs — FORMATTED, MINIFY, SELECTORS, COLORS, STATS — parse the document in memory, so they run on a bounded prefix (the first several megabytes) and tell you when they've truncated. The parser itself is a single linear pass with an explicit stack, so it never recurses into the call stack on deeply nested rules and stays O(n) in the input.
All of it runs in a background Web Worker, so even the megabyte-scale parse never freezes the page — and, as always, nothing is uploaded: the file is read directly by your browser.
← Open the CSS viewer · OmniViewer opens every file format, powered by the same engine as fastjsonviewer.com and hugecsv.com.