Purging Unused CSS with Tailwind and Lightning CSS

Every stylesheet grows. A component library brings its full CSS for the three components you use; an old theme keeps its styles for templates that were deleted two redesigns ago; a Tailwind configuration scans a node_modules package and emits utilities for its source code; someone adds a "just in case" print stylesheet. None of it breaks anything, so none of it gets removed, and the render-blocking stylesheet quietly reaches 180 KB.

This guide removes unused CSS from a static site in two parts: tightening what Tailwind generates, and purging legacy or third-party CSS against the built HTML with PurgeCSS and Lightning CSS. It covers the safelist problem that makes purging risky and how to verify the result. It is part of CSS and JavaScript Asset Optimization. The measurements are from an Astro site with 410 pages using Tailwind 3.4, a component library and a legacy theme stylesheet.

Prerequisites

  • A built site in dist/ and its stylesheets.
  • Chrome DevTools (Coverage panel) and Lighthouse for measurement.
  • Visual regression screenshots of key templates to catch removed rules — see Visual Regression Testing on Preview Deploys.

Step 1: Find Out Where the CSS Comes From

Before deleting anything, attribute the bytes. Build once with source maps (vite.css.devSourcemap in development, or build.sourcemap: true) and load dist/_astro/*.css.map into source-map-explorer:

npx source-map-explorer dist/_astro/index.*.css --html css-report.html

On the Astro site, the 184 KB (uncompressed) stylesheet broke down as follows:

SourceSizeUsed on any page
Tailwind utilities58 KBmostly
Component library CSS71 KB3 of 24 components
Legacy theme CSS39 KBabout 20%
Custom site CSS16 KBmostly
Stylesheet bytes by source before purging Horizontal bars of uncompressed CSS by source. Component library CSS 71 kilobytes, of which only three of twenty-four components were used. Tailwind utilities 58 kilobytes. Legacy theme 39 kilobytes. Custom site CSS 16 kilobytes. Where 184 KB of CSS came from Component library 71 KB Tailwind utilities 58 KB Legacy theme 39 KB Custom site CSS 16 KB
Attributed with source-map-explorer on the production build; the component library alone was larger than the rest of the used CSS.

Step 2: Tighten Tailwind's Content Sources

Tailwind generates a utility when it finds its class name anywhere in the scanned files, including comments, Markdown and dependencies. The Astro site's configuration scanned ./node_modules/@acme/ui/**/*.{js,ts} so that library components could use Tailwind classes — and that also matched the library's documentation strings and tests.

// tailwind.config.mjs
export default {
  content: [
    './src/**/*.{astro,html,md,mdx,ts,tsx}',
    './node_modules/@acme/ui/dist/components/{Button,Tabs,Callout}.js'   // only what is used
  ],
  corePlugins: { container: false },   // unused
  future: { hoverOnlyWhenSupported: true }
};

Narrowing the library glob to the three components in use cut Tailwind's output from 58 KB to 34 KB. In Tailwind 4, the equivalent is @source directives in CSS, and @source not excludes paths.

Watch for dynamically constructed class names — bg-${color}-500 is invisible to the scanner. Map them to complete strings instead:

const tone = { info: 'bg-blue-50 text-blue-900', warn: 'bg-amber-50 text-amber-900' } as const;

Step 3: Stop Importing Whole Libraries

The component library shipped one styles.css with every component. Most libraries now offer per-component CSS files or CSS modules; importing only the three used components' styles removed 62 of its 71 KB. When a library only offers a monolithic file, purging it (Step 4) is the fallback.

Step 4: Purge Legacy CSS Against the Built HTML

For the legacy theme stylesheet — hand-written, years old, used by some templates — the safest purge runs after the build against the actual HTML. PurgeCSS reads every built page and every JavaScript file, collects the selectors they could match, and removes the rest:

// scripts/purge-legacy.mjs
import { PurgeCSS } from 'purgecss';
import { writeFile } from 'node:fs/promises';

const [result] = await new PurgeCSS().purge({
  content: ['dist/**/*.html', 'dist/**/*.js'],
  css: ['dist/_astro/legacy.*.css'],
  safelist: {
    standard: ['is-open', 'is-active', 'dark', /^data-theme/],
    deep: [/^algolia-/, /^pagefind-ui/],
    greedy: [/tooltip/]
  },
  variables: true,
  keyframes: true,
  fontFace: true
});
await writeFile(result.file, result.css);

variables, keyframes and fontFace remove unused custom properties, animations and @font-face blocks too — the last one matters, because an unused @font-face can still trigger a font download if its family name appears in a font-family stack.

Lightning CSS then minifies the result and can lower modern syntax for older browsers in the same pass:

npx lightningcss --minify --targets '>= 0.5%' dist/_astro/legacy.*.css -o dist/_astro/legacy.min.css
Build-time purge pipeline for legacy CSS The site is built. PurgeCSS reads all built HTML and JavaScript files and collects possible selectors. It removes unmatched rules from the legacy stylesheet while keeping safelisted runtime classes. Lightning CSS minifies and lowers syntax. Visual regression screenshots verify key templates before merge. Purging against the built output Build dist with 410 pages Collect selectors HTML plus JS Purge keep safelist Lightning CSS minify and lower Screenshots 12 key templates
Running after the build means content-generated classes are seen exactly as they ship.

Step 5: The Safelist Is the Hard Part

Purge tools only see classes present in the files they read. Anything added later is at risk:

  • State classes added by scripts: is-open, is-active, dark.
  • Third-party widgets that inject markup at runtime: search UIs, consent banners, embeds.
  • CMS content rendered on the client, or HTML fetched after load.
  • Classes built by concatenation in JavaScript.

Two habits keep the safelist short and correct. Prefer attribute selectors for state ([aria-expanded="true"], [data-state="open"]) — they are real attributes in the HTML the tool reads, and they double as accessibility state. And give third-party widget styles their own stylesheet that is never purged, loaded only on pages that use the widget.

After purging, run the site in DevTools with the Coverage panel recording while you exercise interactive elements — open the menu, the search dialog, the theme toggle. Any rule that was needed but removed shows up visually; the screenshots catch the static ones.

Visible and invisible class names for purge tools Two panels. Visible to purge tools are complete class strings in templates or scripts, attribute selectors for state such as aria-expanded, and classes present in the built HTML. Invisible are classes built by string concatenation, markup injected by third-party widgets, and HTML fetched after page load. Class names purge tools can and cannot see Visible complete class strings in source aria-expanded, data-state attributes classes present in built HTML Invisible without safelist class names joined from variables markup injected by widgets HTML fetched after load
Moving state to attributes shrank the safelist from 41 entries to 9 on the Astro site.

Keeping CSS Lean After the Cleanup

A one-off purge decays within months unless something stops the regrowth. Three mechanisms kept the Astro site's CSS stable for the following year.

First, a size budget in CI on the compressed CSS per template, enforced by Lighthouse CI's resourceSizes budget (stylesheet: 15 KB) and reported per pull request with the bundle-size comment described in Tracking Bundle Size per Pull Request. A pull request that adds a library stylesheet shows a jump of tens of kilobytes that reviewers notice.

Second, a Stylelint rule set that forbids @import of whole library stylesheets and flags !important, which tends to accompany overrides of unused library CSS.

Third, co-location. When component styles live next to the component — Astro's scoped <style>, CSS modules, or Tailwind classes in the markup — deleting the component deletes its CSS. The legacy theme stylesheet was the part that grew because no one could tell which template used which rule. After the cleanup, the team moved the surviving 20% of it into the components that used it, and the file was deleted entirely six months later.

Measured Impact

Lighthouse mobile, median of five runs; field p75 from RUM over two weeks.

MeasureBeforeAfter
CSS on docs pages, uncompressed184 KB41 KB
CSS on docs pages, brotli31 KB9 KB
Unused CSS (DevTools Coverage, docs page)76%18%
FCP, lab2.2 s1.9 s
LCP p75, field2.4 s2.1 s
Visual regressions caught before merge—3 (all safelist gaps)

Pitfalls & Rollback

  • Scanning node_modules wholesale. Tailwind emits utilities for any string that looks like a class.
  • Purging third-party widget CSS. Their markup does not exist at build time; keep their CSS separate and unpurged.
  • Removing @font-face blocks that are used by inline styles. Check the font requests before and after.
  • Trusting Coverage on one page. It reports usage for the current page only; test every template.
  • Rollback: the purge is a post-build step and the Tailwind change is configuration. Disable the script or restore the globs and rebuild.

Conclusion

Most unused CSS has an owner you can name: a library imported whole, a content glob that scans too much, a legacy stylesheet nobody dares touch. Attribute the bytes, narrow Tailwind's sources, import only the component styles you use, purge legacy CSS against the built HTML with a short safelist, and keep a budget in CI. On the Astro site that cut compressed CSS by 71% and made the stylesheet small enough to inline.

FAQ

Does Tailwind still need a purge step?

Not a separate one. Tailwind 3 and 4 generate only the utilities found in the files they scan. Unused CSS in Tailwind projects usually comes from content globs that scan too much, plugin output, component libraries with their own stylesheets, or custom CSS layered on top.

How do I avoid purging classes that JavaScript adds at runtime?

Keep a safelist of those classes or patterns, and prefer writing class names as complete strings in source so scanners can find them. Class names built by concatenation, such as a prefix plus a variable, are invisible to every purge tool.

Should purging run against source files or built HTML?

Against built HTML when possible. The built output contains exactly the classes that ship, including ones generated from content and components, so it gives the most accurate result for a static site. Source scanning is faster and is what Tailwind does during development.

How much does removing unused CSS improve performance?

It depends on how blocking and how large the stylesheet was. On the Astro site measured here, cutting compressed CSS from 31 KB to 9 KB reduced lab FCP by about 300 ms and field LCP p75 by 300 ms, and made it small enough to inline.

Filed underHow-tos