✳ OMNIVIEWER CSS viewer SVG viewer ← back

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.

On this page What CSS is Tokens & the grammar A recursive-descent parser Specificity, for real The specificity code Minifying safely Scaling to a huge file

What CSS is

CSSCascading 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:

BucketCountsExamples
aID selectors#main
bclasses, attributes, pseudo-classes.active, [type=text], :hover
ctype selectors, pseudo-elementsdiv, ::before

The universal selector * and combinators (>, +, ~, descendant space) add nothing. Two rules deserve special care:

Try it: 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:

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.

Format a stylesheet →Pretty-print the demo theme.css Rank selectors →Every selector by specificity Extract the palette →Every colour as a swatch

← Open the CSS viewer · OmniViewer opens every file format, powered by the same engine as fastjsonviewer.com and hugecsv.com.