TLDR: When you retrofit Tailwind v4 onto an existing interactive app, split your CSS into two buckets — authored
@layer componentsfor anything JS touches, Tailwind utilities for new static markup only. Purge can't hurt you if the classes it would delete are in an authored layer it never touches.
the dashboard that needed a facelift
My Apollo Dashboard (my local "mission control" cockpit — a Python localhost app that surfaces my Things 3 tasks, calendar, and email) had been running a very plain hand-rolled CSS look.
I wanted to pull in Tailwind v4 for the redesign.
The problem? This app has real JavaScript interactivity. The Today panel drives check-off, cancellation, collapse — all of it through classList toggling at runtime. Classes like .done, .collapsed, .cancelled, .task-checkbox.
the wall: tailwind purges what it can't see
Here's the thing about Tailwind v4's purge step — it's a tree-shaker for CSS classes.
It scans your source files for literal class-name strings. Anything it doesn't find gets stripped from the output CSS.
And the break is completely silent. The JS still adds the class to the DOM. The build still passes. There's nothing in the console. The styles just… aren't there.
I considered using Tailwind's safelist option to whitelist the runtime classes. But that means enumerating every class JS could ever produce — including data-attribute-driven ones composed at runtime — and keeping that list in sync forever. Brittle. Not the answer.
the fix: two buckets
The pattern that works is splitting CSS into two non-overlapping groups.
Bucket A — authored component CSS (purge-immune).
Every class JS touches at runtime goes here, hand-written inside tailwind.css's @layer components { … } block.
The key: content inside @layer components is carried through verbatim. Tailwind never purges authored layer rules — only generated utilities. So .done, .collapsed, .cancelled, every DOM-contract class — they live in Bucket A and they are untouchable.
Bucket B — Tailwind utilities (purge-managed).
Used only on the new static shell markup I'm authoring during the facelift — sidebar, layout grid, page chrome. Markup where those class names ARE literal strings in source, so purge keeps them correctly.
The rule of thumb, and the thing worth writing on a sticky note:
If JS will ever add, remove, or depend on a class → Bucket A. If it's a literal in static HTML you just wrote → Bucket B.
That's it. Those two rules cover every case.
keeping the runtime dep-free
One more constraint I had: the Apollo Dashboard runtime is pure Python stdlib. No Node at runtime.
So Tailwind is dev-time only — I build tailwind.css → static/app.css with npm run build-css, and I commit the built output. The server reads the committed CSS at startup and never needs Node again. The only discipline: re-run the build and commit the CSS whenever I touch classes.
why this matters to me
I've added Tailwind to greenfield apps a dozen times. It's effortless when you own all the markup from day one.
Retrofitting is a different problem — and the silent failure mode is genuinely nasty. Your check-off interaction just stops working with no error anywhere.
The two-bucket split made the Apollo Dashboard facelift safe. The frozen today.py view and its app.js handlers stayed completely untouched. Bucket A held the contract. Bucket B got all the new shiny.
If you're about to pull Tailwind into an existing app with any JavaScript interactivity at all — start here. Map every class your JS touches. Put them in @layer components. Then add Tailwind utilities to everything else.
You'll thank yourself the first time a purge would have silently broken production.