Tailwind CSS is a fantastic tool — until you open a file six months later and find a <div> with forty classes splattered across three wrapped lines. It compiles. It runs. Nobody wants to touch it.
This isn't Tailwind's fault. The framework gives you the rope; it's up to you whether you tie a useful knot or hang yourself with it. The good news is that a handful of deliberate extraction strategies keeps your utility-first code readable, maintainable, and genuinely enjoyable to work in.
- Understanding the problem — what "class soup" actually costs you
- Strategy 1 — Framework component extraction
- Strategy 2 — The
@applydirective (and when not to use it) - Strategy 3 — Class variance authority & compound variants
- Strategy 4 — Utility composition with
cn() - Strategy 5 — Tailwind config as a design token layer
- Decision matrix — when to reach for which tool
The Problem
Consider this perfectly functional Tailwind button:
<button className="inline-flex items-center justify-center gap-2 rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm ring-1 ring-inset ring-indigo-700 hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors duration-150" > Save changes </button>
This is a primary button. One of the most reused elements in any interface. And every time you need it, you paste that entire string again — or worse, you write it slightly differently, accumulating inconsistencies across the codebase.
Class soup isn't just ugly — it's a consistency risk. Every time someone copies that string, the divergence clock starts ticking. — Painful experience, accumulated over many pull requests
The solution isn't to abandon Tailwind. It's to apply the same thinking you already use for JavaScript: don't repeat yourself, encapsulate what varies, and give things names that communicate intent.
Strategy 1 — Framework Component Extraction
The most idiomatic solution — the one the Tailwind docs themselves recommend first — is to extract components at the framework level. In React, Vue, Svelte, or any component-based setup, this means creating a proper component that encapsulates both the markup and the class list.
interface ButtonProps { children: React.ReactNode; variant?: 'primary' | 'secondary' | 'ghost'; size?: 'sm' | 'md' | 'lg'; disabled?: boolean; onClick?: () => void; } const base = "inline-flex items-center justify-center gap-2 rounded-md font-semibold transition-colors duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; const variants = { primary: "bg-indigo-600 text-white hover:bg-indigo-500 ring-1 ring-inset ring-indigo-700", secondary: "bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50", ghost: "text-gray-600 hover:bg-gray-100 hover:text-gray-900", }; const sizes = { sm: "px-3 py-1.5 text-xs", md: "px-4 py-2 text-sm", lg: "px-6 py-3 text-base", }; export function Button({ children, variant = 'primary', size = 'md', ...props }) { return ( <button className={`${base} ${variants[variant]} ${sizes[size]}`} {...props}> {children} </button> ); }
Now your callsites look like this:
<Button variant="primary" size="md">Save changes</Button> <Button variant="ghost" size="sm">Cancel</Button>
Clean, communicative, and refactorable from a single place. This is almost always the right first move — before reaching for any Tailwind-specific tooling.
If you're using Tailwind in a component-based framework, component extraction at the JS/framework level should be your default. Reach for @apply and other tools only when components genuinely can't solve the problem.
Strategy 2 — The @apply Directive
Tailwind's @apply lets you compose utility classes into a single CSS class. It's often the first tool people reach for — and also the most often misused.
When it's appropriate
The honest use cases for @apply are narrow. The clearest one is when you're working with HTML you don't control — server-rendered Markdown, a CMS, a third-party widget — and you need to style it with Tailwind's scale without touching the markup.
.cms-content h2 { @apply text-2xl font-semibold tracking-tight text-gray-900 mt-10 mb-4; } .cms-content p { @apply text-base leading-7 text-gray-700 mb-5; } .cms-content a { @apply text-indigo-600 underline underline-offset-2 hover:text-indigo-800; }
When to avoid it
Don't use @apply just to shorten a class list in templates you control. You lose Tailwind's key benefit — seeing exactly what a thing looks like by reading its classes — without gaining much in return. You also break PurgeCSS/content scanning in edge cases and make it harder to use arbitrary values.
Use @apply for HTML you can't control. For everything else, use component extraction or a variant utility (covered next).
Strategy 3 — Class Variance Authority
For design systems with complex component APIs — multiple variants, sizes, states, and combinations thereof — a tool like Class Variance Authority (CVA) is transformative. It gives you a typed, declarative API for defining how utility classes compose.
import { cva } from 'class-variance-authority'; export const buttonVariants = cva( // Base classes — always applied "inline-flex items-center justify-center rounded-md font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { primary: "bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-600", secondary: "bg-white text-gray-900 ring-1 ring-gray-300 hover:bg-gray-50", danger: "bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-600", ghost: "text-gray-600 hover:bg-gray-100", }, size: { sm: "h-8 px-3 text-xs gap-1.5", md: "h-9 px-4 text-sm gap-2", lg: "h-11 px-6 text-base gap-2.5", }, }, compoundVariants: [ // Ghost danger combo needs a special treatment { variant: 'ghost', size: 'sm', className: "text-xs" }, ], defaultVariants: { variant: 'primary', size: 'md', }, } );
Compound variants are the real superpower here. Need a class that only applies when variant="danger" AND size="lg"? That's one line in CVA — and it's type-safe, meaning TypeScript catches invalid combinations at the editor level.
Strategy 4 — Utility Composition with cn()
Even with great component architecture, you'll constantly need to merge class lists — combining base classes with conditional ones, or letting consumers pass className props. Enter cn(), a small helper that merges clsx with tailwind-merge.
import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
tailwind-merge is the critical ingredient: it resolves conflicting Tailwind utilities intelligently. Without it, px-4 px-6 would both appear in the string, and whichever one CSS encounters first wins — which is never what you want.
export function Card({ className, children }) { return ( <div className={cn( "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", className // consumer can override any utility )}> {children} </div> ); } // Caller overrides padding — tailwind-merge resolves p-6 → p-10 correctly <Card className="p-10 border-indigo-200">...</Card>
Strategy 5 — Config as a Design Token Layer
One underused lever: your tailwind.config file. Instead of scattering arbitrary values like text-[#1a1714] across templates, define semantic tokens at the config level once and use clean utility names everywhere.
export default { theme: { extend: { colors: { brand: { DEFAULT: '#4f46e5', light: '#818cf8', dark: '#3730a3', }, surface: { DEFAULT: '#ffffff', subtle: '#f9fafb', inset: '#f3f4f6', }, }, fontFamily: { display: ['Cal Sans', 'sans-serif'], body: ['Inter Variable', 'sans-serif'], }, spacing: { '18': '4.5rem', '22': '5.5rem', }, }, }, };
Now bg-brand, text-brand-light, and bg-surface-inset are real utilities. When the brand colour changes, you update one line in config — not a grep across 200 files.
Decision Matrix
Here's a quick reference for which tool to reach for in different scenarios:
| Situation | Best approach | Avoid |
|---|---|---|
| Repeated UI in a component framework | Component extraction | Copy-pasting classes |
| Multiple variants + sizes (design system) | CVA | Manual if/else strings |
| Merging classes conditionally or from props | cn() utility | String interpolation |
| Styling HTML you don't control (CMS, markdown) | @apply | Inline style overrides |
| Brand colours / spacing / typography tokens | tailwind.config | Arbitrary values [#hex] |
| Reducing class count in regular templates | Component extraction | @apply (wrong tool) |
Putting It Together
The pattern that emerges across high-quality Tailwind codebases looks something like this:
Design tokens in config
All brand colours, spacing scales, and fonts live in tailwind.config. No arbitrary values in templates.
Primitive components
Button, Input, Badge, Card — each in its own file, variants managed by CVA, className props merged with cn().
Prose styles via @apply
CMS content and rich text output gets styled in a single CSS block, not via class="..." on every element.
Page/feature components
Consume primitives. Tailwind classes here are mostly layout utilities: flex, grid, gap-*, max-w-*.
At this layer, class lists in templates are naturally short — because the visual complexity is handled inside the primitives. You're left writing flex gap-4 items-center instead of an entire design specification on each element.