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:
| Source | Size | Used on any page |
|---|---|---|
| Tailwind utilities | 58 KB | mostly |
| Component library CSS | 71 KB | 3 of 24 components |
| Legacy theme CSS | 39 KB | about 20% |
| Custom site CSS | 16 KB | mostly |
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
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.
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.
| Measure | Before | After |
|---|---|---|
| CSS on docs pages, uncompressed | 184 KB | 41 KB |
| CSS on docs pages, brotli | 31 KB | 9 KB |
| Unused CSS (DevTools Coverage, docs page) | 76% | 18% |
| FCP, lab | 2.2 s | 1.9 s |
| LCP p75, field | 2.4 s | 2.1 s |
| Visual regressions caught before merge | — | 3 (all safelist gaps) |
Pitfalls & Rollback
- Scanning
node_moduleswholesale. 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-faceblocks 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.
Related
- Parent: CSS and JavaScript Asset Optimization — the full pipeline.
- Inlining Critical CSS on Static Sites — what the smaller stylesheet makes possible.
- Minifying HTML, CSS and JS in Eleventy — the next step after purging.
- Eliminating Render-Blocking CSS on Static Sites — why stylesheet size affects LCP.
- Per-Template Performance Budgets — holding the gains in CI.
Filed underHow-tos