The dead classes hiding in your Tailwind markup
Open any Tailwind project that's a year old and grep for class. Somewhere in
there you'll find a p-2 p-4, a flex flex, or a text-lg font-bold text-sm
— a class string where two utilities set the same CSS property and only one of
them survives. Tailwind doesn't warn you. The browser doesn't warn you. The
losing class just sits there, lying to every developer who reads it.
I kept finding these in code reviews — usually after a copy-paste refactor, or in markup a code generator produced — and kept fixing them by hand. So I built tailwind-class-doctor, a small CLI that finds them all and fixes them for me.
What it does
Point it at a directory and it scans anything markup-shaped — JSX/TSX, HTML,
Vue, Svelte, Astro, MDX, plus @apply lines in CSS — and reports five kinds of
class-string rot, each with file:line:column precision:
- duplicate —
flex … flex. Pure noise. - override —
p-2 p-4. Same property, same variants; the earlier one is dead. - shorthand —
pl-1 … p-2. A later shorthand fully covers the earlier specific, so it never applies. - ineffective —
!p-2 p-4. The later class silently loses to an earlier!important. - order — not a bug, just entropy. Consistent ordering makes class strings scannable and keeps diffs quiet.
--fix rewrites every offending class string in place: dedupe, drop the dead
weight, sort into one canonical order. Exit codes (1 = problems) and
--format json make it a one-line CI gate.
The hard part: text-lg vs text-red-500
The naive approach — "two classes with the same prefix conflict" — falls apart
immediately, because Tailwind overloads its prefixes hard. text-* alone can
set font size (text-lg), text alignment (text-center), text wrapping
(text-balance), or color (text-red-500). bg-* covers color, size,
position, repeat, attachment, and gradient images. If your linter thinks
text-sm text-white is a conflict, it's worse than useless.
So the core of the tool is a classifier that maps every utility to a
property group — the actual CSS property it writes. Statics live in a
lookup table; overloaded prefixes get small resolvers that disambiguate by
value shape. The fun edge case is arbitrary values: text-[12px] should read
as a font size but text-[#bada55] as a color, so the resolver inspects the
bracket contents — leading digits and calc( lean length, #/rgb(/label
prefixes like [color:…] lean color. It's a heuristic, but it matches how
tailwind-merge treats the same ambiguity, and it's right in practice.
Two more relationships make the linting honest. First, conflicts are keyed by
variant set, not written order — hover:p-2 vs p-4 is fine, but
md:hover:p-2 vs hover:md:p-4 is a real conflict, because Tailwind applies
both under the same conditions. Second, shorthands get an explicit covers
graph (p covers px covers pl), which is what lets the tool flag
pl-1 … p-2 as dead code while leaving the very intentional reverse pattern —
p-4 px-2, shorthand first, then refine one axis — completely alone. That
asymmetry is the difference between a linter you trust and one you disable.
Everything the classifier doesn't recognize — your btn, your CSS-module
hashes — is deliberately never flagged as a conflict, and template literals
with ${…} interpolation are skipped rather than guessed at. A linter's false
positives are its death; I'd rather miss a weird case than cry wolf.
The whole pipeline is dependency-light (commander for flags, nothing else) and sits at 100% test coverage across statements, branches, functions, and lines — the classifier alone has a couple hundred table-driven cases pinning down every disambiguation branch.
Try it
pnpm add -g tailwind-class-doctor
twdoctor src/ # report problems
twdoctor src/ --fix # rewrite class strings in place
twdoctor -s "p-2 p-4 flex flex" --fix # → "flex p-4"Node ≥ 18, MIT licensed. It installs both tailwind-class-doctor and the
shorter twdoctor.
What's next
Two things are on my list. First, opt-in support for call-expression sources —
clsx(…), cva(…), tw tagged templates — where static string arguments are
lintable even when the full expression isn't. Second, a Tailwind v4 config
awareness pass, so custom utilities registered via @utility can join the
conflict detection instead of being treated as unknowns. If you hit a utility
it misclassifies, an issue with the class string is all I need — the classifier
is a table, and tables are easy to grow.
End of essay



