Avoiding Tailwind Class Soup: Component Extraction Strategies

Your JSX template didn't need 34 utility classes on a single button. Here's how to stay productive with Tailwind without turning your markup into an unreadable wall of text.

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.

The Problem

Consider this perfectly functional Tailwind button:

The offender
<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.

React — Button.tsx
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:

Usage
<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.

Ground rule

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.

global.css — Prose styles for CMS output
.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.

Rule of thumb

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.

button.variants.ts
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.

lib/utils.ts
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.

Usage — overridable className prop
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.

tailwind.config.ts
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:

01

Design tokens in config

All brand colours, spacing scales, and fonts live in tailwind.config. No arbitrary values in templates.

02

Primitive components

Button, Input, Badge, Card — each in its own file, variants managed by CVA, className props merged with cn().

03

Prose styles via @apply

CMS content and rich text output gets styled in a single CSS block, not via class="..." on every element.

04

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.