Blog

CSSGuard: The Missing Half of CSS Tooling

Jonathan Corners | January 3, 2026

Tailwind purges unused CSS. But what about HTML classes with no CSS? We built a bidirectional validator to catch orphaned classes before they break production.

Where Did the Breadcrumbs Go?

My wife is a UX designer. She was paging through our new site on her phone when she asked: “What happened to the breadcrumbs? They disappeared.”

I had just finished cleaning up the codebase. Removed dead code, consolidated templates, tidied the CSS. The kind of housekeeping that makes you feel productive.

But somewhere in that cleanup, I broke the breadcrumbs on mobile. No console errors. No failed builds. Our CI was green (55 checks), because this failure mode is structural—HTML still contains the class tokens, but the purge step removed the CSS definitions. The classes were present. The styles just… weren’t.

I suspected orphaned classes. HTML referencing CSS that no longer existed. So I searched: Is there a tool that does bidirectional CSS validation in the post-Tailwind era?

The answer is no. Not anymore.

The Tooling Gap

For a decade, CSS tooling focused on one direction:

CSS → HTML: “Which CSS rules have no matching HTML elements?”

This made sense. CSS bloat was the problem. Ship megabytes of unused styles, users on 3G cry. Tools like PurgeCSS, UnCSS, and Chrome’s Coverage panel solved this.

But utility-first CSS flipped the script. Now we generate CSS on-demand from HTML classes. The new failure mode is the opposite:

HTML → CSS: “Which HTML classes have no CSS definition?”

Direction Problem Tools
CSS → HTML Bloat (unused CSS) PurgeCSS, UnCSS, Coverage
HTML → CSS Breakage (orphan classes) Nothing modern

The closest thing is html-inspector, which requires PhantomJS. PhantomJS development was suspended in March 2018 when the maintainer stepped down.

CSSGuard

Since we already use Hugo, I went with Go for the implementation. Fast, lightweight, won’t slow down the CI pipeline with slow scans.

CSSGuard performs set operations on two collections:

HTML_CLASSES = { all classes in your HTML }
CSS_CLASSES  = { all selectors in your CSS }

ORPHANS = HTML_CLASSES - CSS_CLASSES  // breaks UI
UNUSED  = CSS_CLASSES - HTML_CLASSES  // bloat

Train Once, Validate Fast

Direct comparison works for small sites. But Tailwind generates thousands of utilities. Comparing text-gray-50 through text-gray-950 literally is wasteful.

CSSGuard uses pattern training:

  1. Train: Parse your purged CSS, identify patterns (text-{color}-{shade}), generate regex
  2. Validate: Match HTML classes against compiled patterns in milliseconds
# Train (once, after CSS build)
cssguard train --css ./public/css/main.css --output cssguard.json

# Validate (every build)
cssguard validate --html ./public --config cssguard.json --fail

When you add a new CSS pattern that doesn’t match trained regex, you re-train. This forces explicit acknowledgment of new patterns entering your vocabulary.

What We Found

We ran CSSGuard against this site. The results were humbling.

$ cssguard direct --css ./static/css/main.css --html ./public

HTML Classes: 463
CSS Classes:  409
Matched:      324 (70.0%)
Orphans:      139 (HTML classes with no CSS)
Unused:       85 (CSS classes not in HTML)

Execution time: 150ms

139 orphan classes. On a site we thought was clean.

The Three Categories

1. Custom classes never defined

We wrote HTML using btn-primary, article-tag, benchmark-bar. These classes had no CSS definition anywhere. They were styled by accident (adjacent Tailwind utilities carried the visual weight) or did nothing at all.

2. Missing theme colors

Our @theme block defined voxell-green but not voxell-gray. The HTML used both:

<div class="bg-voxell-gray/30 border-voxell-gray">  <!-- orphan -->
<span class="text-voxell-green">Works</span>        <!-- matched -->

The gray variants silently failed.

3. Tailwind Play CDN vs. compiled builds

We use the Tailwind Play CDN for quick prototyping. Important distinction: the Play CDN dynamically generates utilities at runtime, but it doesn’t support every JIT variant that a proper build pipeline would. It’s a subset, not a substitute:

bg-blue-500      ✓ in CDN
bg-blue-500/10   ✗ not in CDN (orphan)
bg-blue-500/20   ✗ not in CDN (orphan)

Valid Tailwind syntax. Silent failure with the CDN. Your compiled production build won’t have this problem—but the CDN will.

4. Redundant CSS libraries

Another silent failure mode: adding a component library alongside your Tailwind build. We had Flowbite’s CSS loaded alongside our purged Tailwind output. Both defined the same utilities.

CSSGuard now detects this automatically:

$ cssguard direct --html ./public --css "./main.css,./flowbite.min.css"

Files analyzed: 2
Redundant classes: 303 (defined in 2+ files)

⚠ Redundant CSS (>80% covered):
  - flowbite.min.css (85.2% covered by main.css)

303 classes defined in both files. The Flowbite CSS was 85% redundant—Tailwind already generated those utilities. We removed it, saved 316KB, and our Lighthouse performance score jumped from 74 to 100.

The redundancy check is now part of our CI. If someone adds a vendor CSS file that duplicates our Tailwind output, the build fails.

The Fix: Iteration

Finding orphans is step one. Fixing them is where it gets interesting.

We consolidated all inline <style> blocks into a single CSS file. Defined missing theme colors. Added the custom component classes. Rebuilt.

$ cssguard direct

Matched: 391 (84.4%)
Orphans: 72

Better. But 72 orphans remained. All were Tailwind interactive states: hover:bg-gray-800, focus:ring-2, md:grid-cols-2.

The problem wasn’t our CSS. It was the parser.

The Escaped Colon Bug

Tailwind escapes special characters in CSS selectors. The transformation looks like this:

HTML class:     hover:bg-gray-800
CSS selector:   .hover\:bg-gray-800:hover
After our bug:  .hover:hover  (wrong!)

The bug: our pseudo-class cleaning regex was stripping escaped colons as if they were pseudo-class separators. We saw \:bg-gray-800 and treated it like :hover—removing everything after the colon. The class .hover\:bg-gray-800:hover became .hover:hover after cleaning, matching nothing.

Fix: preserve backslash-escaped colons in the regex.

$ cssguard direct

Matched: 448 (96.8%)
Orphans: 15

What Remains

The final 15 orphans are edge cases:

  • group - Tailwind’s group-hover parent marker
  • space-y-* - wrapped in :where() selectors
  • language-* - syntax highlighting classes

These need either parser improvements or explicit CSS definitions. We chose explicit definitions.

Zero Orphans

After adding the missing utility classes to our CSS:

Metric Before After
HTML Classes 463 463
CSS Classes 409 579
Matched 324 (70.0%) 463 (100%)
Orphans 139 0

From 139 orphans to zero. Every HTML class now has a matching CSS definition.

The 170 additional CSS classes (579 − 409) aren’t bloat—they’re the explicit definitions we added for group, space-y-*, language-*, plus the consolidated inline styles from 12 layout files. You can’t fix what you can’t measure.

After fixing orphaned classes, our UI stabilized and the site now holds a perfect 100 score on Google PageSpeed. I’d never even tried for a perfect score before—it always felt like chasing diminishing returns. But once CSSGuard eliminated the orphan noise, the remaining issues were obvious and fixable. Clean CSS foundations make everything else easier.

The Uncomfortable Truth

70% match rate means 30% of our CSS classes were either orphaned or unused. On a small marketing site with 27 pages.

We don’t think our codebase is unusually messy. We suspect this ratio is common. Most teams just don’t have tooling to measure it.

Performance

Mode Time Use Case
train 45ms One-time after CSS build
validate 12ms CI, pre-commit hooks
direct 150ms Full analysis

150ms to scan 27 HTML files and parse a 58KB CSS file. Fast enough for CI. Fast enough for pre-commit.

Get It

go install github.com/JCorners68/cssguard/cmd/cssguard@latest

Source and whitepaper: github.com/JCorners68/cssguard


Built because we shipped broken breadcrumbs to production. If it saves you the same bug, it was worth it.

Want to discuss this?

Get in touch →