Utility Soup : Diagnosis and Refactor
"Utility soup" describes a Tailwind element with so many utility classes that the line wraps multiple times and the reader cannot parse it. It is a symptom, not always a bug. This skill teaches when to leave it, when to extract, and how to keep order stable with tooling.
ALWAYS apply the 3-use rule : extract on the THIRD copy of a pattern, not the first. Premature extraction is worse than long class lists.
Companion skills :
tailwind-impl-apply-directive: @apply + @layer semanticstailwind-core-philosophy: utility-first design principlestailwind-impl-config-v4: v4 CSS-first config surface
Quick Reference
The 3-use rule
| Copies of the pattern | Action |
|---|---|
| 1 | Leave inline. |
| 2 | Leave inline. Add a TODO comment if the duplication is intentional. |
| 3+ | Extract a component (preferred) or @apply class (when no framework). |
Decision matrix
Repeated 3+ times AND
├── Framework available (React, Vue, Svelte, Solid) : extract a component
├── Template language (Blade, ERB, Nunjucks, Twig) : extract a partial
└── Plain HTML / MDX / third-party widget : @apply in @layer components
Tooling baseline
| Tool | Purpose |
|---|---|
prettier-plugin-tailwindcss |
Sort class names on save (stable diffs). |
eslint-plugin-tailwindcss |
Lint for duplicate / contradicting / unknown classes. |
| Headwind VS Code extension | Sort inside the editor (alternative to Prettier). |
ALWAYS install at least Prettier sorting. NEVER ship a codebase where class order is decided per author.
Decision Trees
Is my long class list actually a problem ?
Count the utility classes on the element.
├── < 15 utilities : not utility soup, move on
├── 15-25 utilities, single component : acceptable for a leaf node
├── 25+ utilities : continue
2. Is the pattern repeated elsewhere ?
├── No : it is a leaf component. Long is acceptable.
│ Apply multi-line formatting (see Patterns).
└── Yes : continue
3. How many copies exist ?
├── 1 or 2 : leave inline. Add a TODO if intentional duplication.
└── 3+ : extract (see decision matrix).
Component or @apply ?
Are you in a component framework (React, Vue, Svelte, Solid) ?
├── Yes : ALWAYS a component. Pass props for variants.
└── No : continue
Are you in a template language (Blade, ERB, Twig, Nunjucks, Astro) ?
├── Yes : ALWAYS a partial / include / slot.
└── No : continue
Are you in plain HTML, MDX, or overriding a third-party widget ?
├── Yes : @apply in @layer components is the right tool.
└── No : revisit. The cases above cover ~99% of real codebases.
Prettier vs ESLint vs Headwind ?
Goal ?
├── Stable class order across the team : prettier-plugin-tailwindcss
├── Catch unknown / duplicate / contradicting class names : eslint-plugin-tailwindcss
├── Editor-side sorting without running Prettier : Headwind VS Code extension
These are NOT mutually exclusive. Use Prettier as the build-time formatter and ESLint for class validation in CI. Headwind is a developer-comfort tool that overlaps with Prettier ; pick one to avoid conflicting sort orders.
Patterns
Pattern : multi-line formatting for legitimate long lists
<button
className="
inline-flex items-center justify-center
rounded-md px-4 py-2
text-sm font-medium
bg-blue-500 text-white
hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition
"
>
Save
</button>
ALWAYS group by concern (layout, sizing, spacing, color, state) on each line. Prettier sorting reshuffles inside its own group order ; do NOT fight it.
Pattern : extracting a component (preferred)
Before (utility soup repeated 5 times) :
<button className="inline-flex items-center rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600">Save</button>
<button className="inline-flex items-center rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600">Confirm</button>
<button className="inline-flex items-center rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-300">Cancel</button>
After :
function Button({ variant = "primary", children }: { variant?: "primary" | "secondary", children: ReactNode }) {
const base = "inline-flex items-center rounded-md px-4 py-2 text-sm font-medium"
const variants = {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
}
return <button className={`${base} ${variants[variant]}`}>{children}</button>
}
<Button>Save</Button>
<Button>Confirm</Button>
<Button variant="secondary">Cancel</Button>
Pattern : @apply when no framework is available
src/styles/components.css (loaded once) :
@layer components {
.btn-primary {
@apply inline-flex items-center rounded-md bg-blue-500 px-4 py-2
text-sm font-medium text-white hover:bg-blue-600;
}
.btn-secondary {
@apply inline-flex items-center rounded-md bg-gray-200 px-4 py-2
text-sm font-medium text-gray-900 hover:bg-gray-300;
}
}
<button class="btn-primary">Save</button>
ALWAYS wrap in @layer components so utility overrides still win.
See tailwind-impl-apply-directive for full semantics.
Pattern : Prettier sorting setup
Install :
npm install -D prettier prettier-plugin-tailwindcss
.prettierrc.json v3 :
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindConfig": "./tailwind.config.js",
"tailwindFunctions": ["clsx", "cva", "tw", "twMerge"],
"tailwindAttributes": ["myClassList"]
}
.prettierrc.json v4 :
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"tailwindFunctions": ["clsx", "cva", "tw", "twMerge"]
}
ALWAYS list every class-building function in tailwindFunctions so
Prettier sees the classes inside clsx("foo bar baz"). NEVER rely on
Prettier auto-detecting these.
Pattern : ESLint validation setup
Install :
npm install -D eslint eslint-plugin-tailwindcss
eslint.config.js (flat config, ESLint 9+) :
import tailwind from "eslint-plugin-tailwindcss"
export default [
...tailwind.configs["flat/recommended"],
{
settings: {
tailwindcss: {
config: "./tailwind.config.js", // v3
// or for v4 :
// cssFiles: ["./src/app.css"],
callees: ["clsx", "cva", "tw", "classnames"],
whitelist: ["btn-primary", "card", "container-narrow"],
},
},
},
]
ALWAYS list custom class names in whitelist, otherwise
no-custom-classname flags them as unknown.
Pattern : Class-building helper functions
import clsx from "clsx"
function Card({ active, className }: { active: boolean, className?: string }) {
return (
<div className={clsx(
"rounded border bg-white p-4 shadow",
active && "border-blue-500 ring-2 ring-blue-500",
className,
)}>
...
</div>
)
}
Combine with tailwind-merge for safe override semantics when both
the component and the consumer pass overlapping utilities :
import { twMerge } from "tailwind-merge"
className={twMerge("p-4 px-6", className)}
twMerge resolves p-4 px-6 correctly (the later px-6 wins for
horizontal padding while p-4 survives for vertical).
Anti-Patterns (summary)
NEVER extract after the first duplicate. The 3-use rule exists because two duplicates is too small a sample to know the right abstraction.
NEVER reach for @apply when a component is available. @apply is
for non-framework contexts.
NEVER let class order drift. Install Prettier sorting on day one.
NEVER copy-paste 30 utilities across 5+ files. That is the extraction trigger.
NEVER add eslint-plugin-tailwindcss without configuring whitelist
for your custom class names. Day-one onboarding error.
See references/anti-patterns.md for the full catalog.
Reference Links
references/methods.md: Prettier plugin options, ESLint rules, Headwind config, helper-function patternsreferences/examples.md: end-to-end refactors from soup to component, soup to @apply, soup to partialreferences/anti-patterns.md: premature extraction, @apply abuse, Prettier vs Headwind conflict, ESLint whitelist mistakes
Sources
- Tailwind extraction guidance : https://tailwindcss.com/docs/styling-with-utility-classes
- prettier-plugin-tailwindcss : https://github.com/tailwindlabs/prettier-plugin-tailwindcss
- eslint-plugin-tailwindcss : https://github.com/francoismassart/eslint-plugin-tailwindcss
- Headwind extension : https://github.com/heybourn/headwind
- tailwind-merge : https://github.com/dcastil/tailwind-merge
- clsx : https://github.com/lukeed/clsx