Tailwind CSS's utility-first approach can lead to bloated JSX if not organized properly. In 2026, the ecosystem has converged on patterns like class-variance-authority (cva) for variant management, the cn() helper for class merging, @apply for truly repeated patterns, and Headless UI for accessible interactive components. This guide covers all of them.
class-variance-authority (cva): Type-Safe Variants
cva is the standard way to manage component variants in Tailwind. It gives you full TypeScript types for variants, default variants, and compound variants — all without CSS-in-JS.
// Using class-variance-authority (cva) for type-safe variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base classes applied to every variant
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
// Usage:
// <Button variant="outline" size="sm">Click me</Button>
// <Button variant="destructive">Delete</Button>When to Use @apply
@apply extracts repeated Tailwind class combinations into CSS classes. Use it sparingly — only for patterns repeated 10+ times across many files. Most patterns belong in React components.
/* globals.css — using @apply to extract reusable patterns */
@layer components {
/* Card component pattern */
.card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.card-header {
@apply flex flex-col space-y-1.5 p-6;
}
.card-title {
@apply text-2xl font-semibold leading-none tracking-tight;
}
.card-content {
@apply p-6 pt-0;
}
/* Form field pattern */
.form-label {
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
}
.form-input {
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
/* Badge pattern */
.badge {
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
}
}
/* Use sparingly — prefer utility classes in JSX for maintainability */The cn() Utility: Merging Classes Safely
clsx handles conditional classes, and tailwind-merge resolves Tailwind conflicts (e.g., px-4 + px-6 → px-6). Together they form the cn() utility used in every shadcn/ui component.
// lib/utils.ts — the cn() helper (used everywhere in shadcn/ui)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// clsx: conditionally join class names
// twMerge: intelligently merge Tailwind classes (handles conflicts)
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Examples:
cn('px-4 py-2', 'px-6') // 'py-2 px-6' (px-4 overridden)
cn('text-red-500', isActive && 'text-blue-500') // conditional
cn({ 'opacity-50': disabled, 'cursor-not-allowed': disabled })Headless UI: Accessible Interactive Components
Headless UI (by Tailwind Labs) provides fully accessible interactive components with zero styling. You bring the Tailwind classes; it handles ARIA attributes, keyboard navigation, and focus management.
// Using Headless UI (Tailwind Labs) for accessible components
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment, useState } from 'react';
const people = [
{ id: 1, name: 'Durward Reynolds' },
{ id: 2, name: 'Kenton Towne' },
{ id: 3, name: 'Therese Wunsch' },
];
export function Select() {
const [selected, setSelected] = useState(people[0]);
return (
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
<Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{people.map((person) => (
<Listbox.Option key={person.id} value={person} className={({ active }) => cn('relative cursor-default select-none py-2 pl-10 pr-4', active ? 'bg-amber-100 text-amber-900' : 'text-gray-900')}>
{({ selected }) => (
<>
<span className={cn('block truncate', selected ? 'font-medium' : 'font-normal')}>{person.name}</span>
{selected ? <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600"><CheckIcon className="h-5 w-5" /></span> : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
);
}Tailwind Config: Design Tokens and Theming
Extending Tailwind with CSS custom properties (variables) enables theme switching without JavaScript. This is the pattern used by shadcn/ui and Radix UI themes.
// tailwind.config.ts — extend with design tokens
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// CSS variable-based colors for theming
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;Component Library Approaches
| Library | Approach | Code Ownership | Theming | Accessibility |
|---|---|---|---|---|
| shadcn/ui | Copy-paste + Radix + CVA | You own the code | CSS variables | Radix primitives |
| Chakra UI | Component library | npm dependency | Theme object | Built-in |
| MUI (Material) | Component library | npm dependency | Theme provider | Built-in |
| Mantine | Component library | npm dependency | CSS variables | Built-in |
| DaisyUI | Tailwind plugin | Config only | CSS variables | Manual |
Best Practices
- Use cva for any component with 2+ variants. It catches typos at compile time and auto-generates TypeScript types.
- Always use cn() (twMerge + clsx) to merge classes. Never concatenate strings directly — Tailwind conflicts will break styles.
- Avoid @apply for components. Use it only for truly global patterns like .prose or third-party library overrides.
- Design tokens via CSS variables in tailwind.config.ts enable dark mode and theming without duplicating utility classes.
- For accessible dropdowns, modals, tooltips — use Headless UI or Radix UI primitives. Rolling your own is bug-prone.
Frequently Asked Questions
Should I use Tailwind or a CSS-in-JS library like styled-components?
In 2026, Tailwind with CVA and shadcn/ui is the dominant pattern for new React projects. CSS-in-JS has performance overhead with React Server Components (they serialize styles to HTML). Tailwind generates static CSS at build time, has zero runtime overhead, and works natively with RSC.
What is shadcn/ui and should I use it?
shadcn/ui is a collection of copy-paste components built with Radix UI + Tailwind + CVA. Unlike traditional component libraries (MUI, Chakra), you own the source code — components live in your repo. This means full customization without fighting library styles. Highly recommended for new projects.
How do I handle dark mode with Tailwind?
Add darkMode: "class" to tailwind.config.ts. Then add the dark: prefix to utility classes (e.g., dark:bg-gray-900). Toggle by adding/removing the dark class on the html element. With CSS variables and design tokens, you can update a single :root block for complete theme switches.
How do I prevent Tailwind class purging in production?
Configure the content array in tailwind.config.ts to include all files that use Tailwind classes. Never construct class names dynamically (e.g., bg-${color}-500) — Tailwind's purge cannot analyze interpolated strings. Use the full class name in your source code.
Is Tailwind CSS good for design systems?
Yes, but requires discipline. Use design tokens (CSS variables) mapped to Tailwind colors/spacing. Create a component library using cva. Document all variant combinations. Teams at Vercel, Linear, and PlanetScale use Tailwind-based design systems at scale.