Issue №140 Autumn 2026A journal of dev tools, libraries & small ideasUpdated weekly
devops · 4 min read
devopsAugust 20, 2026 · 4 min

tailwind-class-doctor

tailwind-class-doctor is a command-line linter for Tailwind CSS class strings that finds the rot a stylesheet linter can't see: exact duplicates (`flex flex`), same-property conflicts where only the last class survives (`p-2 p-4`), specifics left dead under a later shorthand (`pl-1 … p-2`), classes

tailwind-class-doctor — live demo screenshot
01
devops

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:

  • duplicateflex … flex. Pure noise.
  • overridep-2 p-4. Same property, same variants; the earlier one is dead.
  • shorthandpl-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

About the author
Er An Khoo