Auditing Unused Tailwind Classes: Content Scanning and Bundle Hygiene
AI generated
</>
tw
Tailwind CSS · Bundle Hygiene · Performance
Auditing unused Tailwind classes
content scanning and bundle hygiene

Tailwind's content scanning promises that only actually used classes end up in the final CSS. In practice, unused Tailwind classes still accumulate, through removed components, oversized safelists, and dynamically composed class names that scanning can never classify as safe in the first place.

17 min read Tailwind CSS v4 · Content scanning · CSS coverage Bundle analysis · CI integration

1. Why unused Tailwind classes appear at all

At first glance, the problem of unused Tailwind classes seems paradoxical, after all Tailwind only generates CSS for classes that content scanning actually finds in the source code. Yet significant CSS bloat still accumulates in grown projects over time, and the cause is almost never the scanning mechanism itself, but the gap between what once existed in the code and what actually gets rendered today.

The most common trigger is deleted or refactored code that leaves behind class references which technically still exist in the source but are practically never reached anymore, for example in commented out code, unused feature flag branches or old test fixtures. The second common trigger is an overly cautious safelist that permanently forces classes into the bundle even though the dynamic use case they were originally meant for no longer exists. Both cases lead to unused Tailwind classes that nobody actively added, but that arise as a byproduct of normal code evolution.

2. How content scanning works and where it hits limits

Tailwind's content scanning searches all configured source files for text patterns that look like valid utility classes and generates the corresponding CSS rule for every match. This mechanism is deliberately kept simple: no real code analysis takes place, just plain text matching, which also finds matches in comments, in string literals or in code that is no longer reachable. This very simplicity is exactly the reason why unused Tailwind classes can end up in the final bundle at all, even though scanning technically works correctly.

A second edge case in content scanning concerns file formats that are not covered by the content configuration. If a component is pulled in from a monorepo package whose path is not explicitly listed in the scanning glob, Tailwind generates no CSS at all for its classes, which results in missing rather than unused Tailwind classes, but produces the same debugging effort, since both symptoms show up identically in the browser as missing styling.


# Quick sanity check: does the content config actually cover every source path?
grep -A 5 "content:" tailwind.config.js

# Cross-check against the real project structure
find . -type d -name "node_modules" -prune -o \
  -type f \( -name "*.html" -o -name "*.phtml" -o -name "*.twig" \) -print \
  | wc -l

3. Analysis tools: CSS coverage in the DevTools

The most direct way to identify unused Tailwind classes in actual operation is the Coverage tab of the Chrome DevTools. After a page loads, this tab shows exactly what percentage of the loaded CSS was actually applied while rendering the current page, broken down to individual byte ranges within the file. For a single page view, this delivers an instant, reliable snapshot, but has one important limitation: it only measures what is needed on the currently loaded page, not what is relevant across the whole project over all pages.

For a project wide statement about unused Tailwind classes, an automation is therefore needed that aggregates coverage data across several representative page types, for example home page, product detail page, checkout and account page. Only the intersection of unused rules across all tested pages can be classified as truly dead code with high confidence, while a rule used only on a single, untested page can falsely appear unused.


// coverage-audit.js — aggregate CSS coverage across multiple page types
import puppeteer from "puppeteer";

const pagesToCheck = [
  "https://staging.example.com/",
  "https://staging.example.com/catalog/product/view/id/42",
  "https://staging.example.com/checkout/cart",
  "https://staging.example.com/customer/account",
];

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.coverage.startCSSCoverage();

for (const url of pagesToCheck) {
  await page.goto(url, { waitUntil: "networkidle0" });
}

const coverage = await page.coverage.stopCSSCoverage();
let usedBytes = 0;
let totalBytes = 0;

for (const entry of coverage) {
  totalBytes += entry.text.length;
  for (const range of entry.ranges) {
    usedBytes += range.end - range.start;
  }
}

console.log(`CSS usage across ${pagesToCheck.length} pages: ${((usedBytes / totalBytes) * 100).toFixed(1)}%`);
await browser.close();

4. Custom scripts: matching generated CSS against real usage

Besides browser coverage data, a static comparison at build level is worthwhile: a script extracts every class name from the generated Tailwind CSS and checks for each one whether it still occurs as an exact string anywhere in the current source code. This approach finds unused Tailwind classes that were syntactically validly generated but no longer belong to any actually existing code reference, for example because the component that once used them was deleted entirely without the corresponding safelist entries being removed as well.

The advantage over pure browser coverage is that such a script covers the entire source code, not just the pages actually visited in a test run. The downside: it does not detect classes that exist in the code but are never reached at runtime due to conditional logic, for example in a permanently disabled feature flag branch. Both methods therefore complement each other rather than replacing each other, when it comes to fully capturing unused Tailwind classes.


// find-unused-classes.js — cross-check generated CSS against source usage
import fs from "node:fs";
import { glob } from "glob";

const cssContent = fs.readFileSync("pub/static/css/tailwind.min.css", "utf8");
const generatedClasses = new Set(
  [...cssContent.matchAll(/\.([a-zA-Z0-9_-]+)\s*[{,:]/g)].map((m) => m[1])
);

const sourceFiles = await glob("src/templates/**/*.{html,phtml,twig}");
const usedClasses = new Set();

for (const file of sourceFiles) {
  const content = fs.readFileSync(file, "utf8");
  for (const cls of generatedClasses) {
    if (content.includes(cls)) usedClasses.add(cls);
  }
}

const unused = [...generatedClasses].filter((c) => !usedClasses.has(c));
console.log(`${unused.length} of ${generatedClasses.size} generated classes appear unused.`);
console.log(unused.slice(0, 30).join("\n"));

5. Dynamically generated class names as blind spots

A particularly stubborn special case among unused Tailwind classes involves class names assembled at runtime from variables, such as text-${statusColor}-600. Content scanning fundamentally cannot detect such patterns, because no complete class name exists as a string in the source code. Projects usually solve this with a safelist that lists all possible combinations up front, which automatically causes combinations for values that never occur in the current use case to remain unused Tailwind classes in the bundle permanently.

The more sustainable solution is to avoid dynamic class composition where possible and instead maintain an explicit lookup table in the code that maps every possible state to a complete, scanning visible class. This slightly increases the line count in the code, but makes every actually needed class visible to Tailwind's scanning and makes a safelist with dozens of speculative combinations unnecessary, most of which are never needed anyway.


/* PROBLEM: dynamic class composition is invisible to content scanning */
/* text-${statusColor}-600 requires an oversized safelist to work at all */

/* BETTER: explicit lookup table, every class is a real, scannable string */
/*
  const statusColorClass = {
    success: "text-green-600",
    warning: "text-amber-600",
    error: "text-red-600",
    pending: "text-slate-600",
  }[status];
*/

6. Safelist hygiene: identifying outdated entries

The safelist configuration is one of the most common sources of unused Tailwind classes, because by definition it forces classes into the bundle regardless of actual usage. Over years, safelists tend to grow without an entry ever being removed again, because removing an entry, unlike adding one, carries a perceived risk: nobody wants to accidentally remove a class that might still be needed somewhere. This caution, however, is exactly what causes the CSS bloat one is trying to avoid.

A regular, planned safelist revision resolves this dilemma: every safelist entry is trial removed for a limited period while an automated visual regression test runs across all important pages. If nothing breaks visibly, the entry was, with high confidence, an unused Tailwind class and can be removed permanently. This process should be repeated at fixed intervals, since new outdated safelist entries accumulate again with every product change.

7. Component libraries as a source of CSS bloat

Projects that share a central component library across several products or frontends frequently produce unused Tailwind classes, because the library is built for the greatest common denominator of all consumers, while each individual frontend only actually uses a subset of the variants. A button component with eight color variants and four sizes potentially generates 32 combinations of generated classes, even though a concrete project might only use four of them in practice.

The most reliable way to reduce this type of CSS bloat is a strict separation between the component library as a source of utility combinations and the actual content scanning target of each individual consuming project. Instead of including the entire library indiscriminately in the content configuration of every frontend, only the section actually used in the respective project should be scanned, which prevents unused Tailwind classes from never used variants from the outset.

8. CI integration: automated bundle size checks

Manual audits of unused Tailwind classes rarely happen regularly in practice, because they cost time and easily fall by the wayside. An automated CI check that compares the CSS bundle size against the last known value on every pull request makes unusual growth visible right away, without requiring a manual audit. If the size change exceeds a defined threshold, the build gets flagged as a warning instead of the growth accumulating unnoticed.

In addition, a periodic, weekly CI job can run the full coverage analysis across all representative pages and document the result as a comment in a dedicated tracking issue. This keeps the state of unused Tailwind classes in the project traceable at all times, without anyone having to remember the topic manually before the bundle is already noticeably bloated.


#!/usr/bin/env bash
# ci-bundle-size-check.sh — fail the build on unexpected CSS growth
set -euo pipefail

CURRENT_SIZE=$(stat -f%z pub/static/css/tailwind.min.css 2>/dev/null \
  || stat -c%s pub/static/css/tailwind.min.css)
BASELINE_SIZE=$(cat .ci/css-baseline-bytes.txt)
THRESHOLD_PERCENT=5

DIFF=$(( (CURRENT_SIZE - BASELINE_SIZE) * 100 / BASELINE_SIZE ))

echo "Baseline: ${BASELINE_SIZE} bytes, current: ${CURRENT_SIZE} bytes (${DIFF}% change)"

if [[ $DIFF -gt $THRESHOLD_PERCENT ]]; then
  echo "[WARNING] CSS bundle grew by more than ${THRESHOLD_PERCENT}%. Review for unused classes."
  exit 1
fi

9. Analysis methods compared

The following table compares the methods presented for tracking down unused Tailwind classes by accuracy, degree of automation and typical use case.

Method Accuracy Automatable Typical use
DevTools coverage High, per page Partially (Puppeteer) Spot-check deep analysis
Static class matching Medium Yes, fully Regular project audit
Safelist revision High, manually verified Low Periodic cleanup
CI bundle size check Low, trend only Yes, fully Continuous monitoring

No single method fully covers unused Tailwind classes. The most robust strategy combines an automated CI check for ongoing monitoring with periodic, deeper audits through coverage analysis and safelist revision, instead of relying on a single tool.

Mironsoft

Web performance, CSS bundle analysis and Tailwind optimization for Magento and Hyvä

A Tailwind bundle full of dead classes?

We analyze your CSS bundle with coverage tools and custom scripts, clean up outdated safelist entries, and set up CI checks that permanently prevent future CSS bloat.

Bundle audit

Coverage analysis across all important page types with concrete numbers

Safelist cleanup

Safe revision of outdated entries with visual regression testing

CI integration

Automated bundle size checks in your pipeline

10. Summary

Unused Tailwind classes almost never result from a bug in content scanning itself, but from the natural gap between deleted code, dynamically generated class names and oversized safelist configurations that grow over time without cleanup. DevTools coverage delivers precise snapshots of individual pages, static class matching covers the entire project, and regular safelist revisions remove the biggest single chunk of dead CSS.

The most sustainable protection against unused Tailwind classes is a combination of periodic, deep audits and an automated CI check that immediately reports any unusual bundle growth. This keeps cleanup a continuous, low effort process instead of a rare but all the more painful major undertaking.

Auditing unused Tailwind classes: the key points at a glance

Main causes

Deleted code, oversized safelists and dynamic class composition that content scanning cannot detect.

DevTools coverage

Precise per page, but must be aggregated across multiple page types for a project wide statement.

Safelist hygiene

Regular, trial removal of individual entries with a visual regression test uncovers dead entries.

CI safeguard

Automated bundle size check reports unusual growth immediately on every pull request.

11. FAQ: Auditing Unused Tailwind Classes

1How do unused classes appear despite scanning?
Text matching finds classes in deleted code and oversized safelists.
2Fastest way for a single page?
Coverage tab of Chrome DevTools shows actual CSS usage immediately.
3Is one page enough for analysis?
No, multiple page types need aggregation, otherwise false positives.
4Why are safelists a common source?
They force classes regardless of usage and are rarely cleaned up out of caution.
5How do I find outdated safelist entries?
Trial remove and run a visual regression test across all important pages.
6What about dynamic class names?
Explicit lookup table instead of template string makes safelist unnecessary.
7Do component libraries cause bloat?
Yes, common denominator of all consumers creates variants individual projects never use.
8Is removal fully automatic?
Scripts identify candidates, final removal needs a visual regression test.
9How do I monitor this continuously?
CI check compares bundle size against the last value on every pull request.
10Missing vs. unused, what's the difference?
Missing: content path does not cover the folder. Unused: generated, but no longer needed anywhere.