Our site ships two stylesheets totalling 107,598 bytes. I have counted the rules in them before, from the outside. This time I read the CSSOM on a live page and asked what kind each rule is, which turned out to be the more interesting question.

const kinds = {}; const walk = (list) => { for (const r of list) { kinds[r.constructor.name] = (kinds[r.constructor.name] || 0) + 1; if (r.cssRules && !(r instanceof CSSStyleRule)) walk(r.cssRules); } }; for (const s of document.styleSheets) walk(s.cssRules); 

On our FAQ page:

rule type count
CSSStyleRule 1,254
CSSSupportsRule 97
CSSPropertyRule 72
CSSFontFaceRule 13
CSSMediaRule 13
CSSContainerRule 5
CSSLayerBlockRule 4
CSSKeyframesRule 4
CSSLayerStatementRule 1

The trap in that snippet, which cost me twenty minutes

My first version recursed whenever a rule had cssRules and counted only the leaves. It reported 90 rules. The stylesheet has 1,254.

CSSStyleRule now exposes a cssRules list, because CSS nesting means a style rule can contain style rules. So a naive walk recurses into every style rule, finds no children, and the "leaf" count collapses to whatever happened to be at the bottom of the tree. If you have a CSSOM counter written before nesting shipped, it is wrong now, and it fails quietly in the direction of making your stylesheet look tiny.

72 registered custom properties, and all of them typed as anything

for (const r of allRules) if (r instanceof CSSPropertyRule) console.log(r.name, r.syntax, r.inherits, r.initialValue); 

All 72 are Tailwind internals, --tw-translate-x, --tw-scale-y, --tw-shadow-color, and so on. The breakdown:

count
syntax: "*", meaning untyped 62
<color> 3
<length-percentage> 3
<percentage> 3
<length> 1
inherits: false 72 of 72
no initial value 44

So the headline feature of @property, giving a custom property a type the engine can validate and interpolate, is used on 10 of 72. The other 62 are registered for the other two reasons: inherits: false, so a transform variable set on a parent does not leak into every child, and an initial value, so translate-x has a sane 0 before anything sets it.

On <body>, 28 of the 72 resolve to a value. --tw-translate-x is 0, --tw-border-style is solid, --tw-gradient-from is rgba(0, 0, 0, 0), and --tw-blur, --tw-duration, --tw-ease and 41 others are empty, which is what an initial-less non-inherited registration looks like from the outside.

And then the same 72, again

Of the 97 @supports blocks, four are unique conditions. This is one of them:

@supports ( ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) ) { *, ::before, ::after, ::backdrop { --tw-translate-x: 0; --tw-translate-y: 0; /* ... 70 more ... */ } } 

One rule, 72 declarations, the same 72 names. That condition is not a feature query in spirit. -webkit-hyphens with no margin-trim is "Safari before 16.4"; -moz-orient without relative colour syntax is "Firefox before 128". Those are exactly the two versions that shipped @property, so the query means "this engine has no @property", expressed through four unrelated properties because there is no way to ask the real question. It is a browser sniff written in the only syntax CSS gives you for one.

The logic is sound once you see it. Engines without @property ignore the 72 registrations entirely, so --tw-translate-x would be an empty string rather than 0, and every transform utility built out of it would break. The fallback sets them by hand on the universal selector. It must not run where @property works, because a universal-selector declaration beats a registration's initial value and would reintroduce the inheritance it exists to prevent. Hence the sniff, and hence the same 72 variables appearing twice in a file we ship to everybody.

It costs us roughly 2 KB uncompressed, and it is a good illustration of what "zero-runtime CSS" means in practice: the runtime did not disappear, it got compiled into a feature query.

93 of the 97 supports blocks ask the same question

@supports (color: color-mix(in lab, red, red)) { .border-amber-500\/25 { border-color: color-mix(in oklab, var(--color-amber-500) 25%, transparent); } } 

91 style rules across 93 blocks, every one of them an opacity modifier: bg-background/90, border-border/60, text-muted-foreground/70. Every slash in a Tailwind v4 class is a color-mix(), and every color-mix() gets its own guard.

The other two conditions are worth a look as well. @supports (color: lab(0% 0 0)) wraps a single :root, :host rule holding the entire default colour palette expressed in Lab. And this one:

@supports ((-webkit-backdrop-filter: var(--tw)) or (backdrop-filter: var(--tw))) { .supports-\[backdrop-filter\]\:bg-background\/75 { ... } } 

That is the class on our site header, the one element on the page Chrome gives its own compositing layer to. The feature query I wrote in the markup as supports-[backdrop-filter]: becomes a real @supports block in the output, which is obvious in hindsight and still satisfying to see.

One cascade layer is declared and never used

@layer properties, theme, base, components, utilities; <- 5 names properties 1 rule theme 2 rules base 54 rules utilities 1,191 rules components (no block in the delivered CSS) 

Four blocks for five declared names. components is reserved in the layer order and nothing ever lands in it, which is the correct state for a project with no @layer components of its own, and still an oddity worth knowing is there: the cascade position exists, so adding one rule to it later would slot in below every utility without touching anything else. 1,248 of the 1,254 style rules are inside one of those four blocks; six live outside any layer.

Four animations, one keyframe each

@keyframes enter { 0% { opacity: var(--tw-enter-opacity,1); ... } } @keyframes exit { 100% { opacity: var(--tw-exit-opacity,1); ... } } @keyframes spin { 100% { transform: rotate(360deg); } } @keyframes pulse { 50% { opacity: 0.5; } } 

Every one is a single-keyframe animation, relying on the implicit from and to that the engine builds out of the element's current computed style. Four @keyframes, four CSSKeyframeRules in total. Nothing wrong with it, and it is the kind of thing a rule census surfaces and a stylesheet read never does.

Worth running on your own build

const kinds = {}; const walk = (list) => { for (const r of list) { kinds[r.constructor.name] = (kinds[r.constructor.name] || 0) + 1; if (r.cssRules && !(r instanceof CSSStyleRule)) walk(r.cssRules); } }; for (const s of document.styleSheets) { try { walk(s.cssRules); } catch {} } console.table(kinds); 

Four questions it answers in one paste: how many of your rules are inside a feature query and what that query is really testing, whether anything is declared twice for compatibility, how many cascade layers you have against how many you use, and whether your rule counter was written before CSS nesting existed. Try it in the console on any page of ours first, then on your own, and compare the shapes.