clsx & tailwind-merge
Class merging in Tailwind: clsx, tailwind-merge, cva, and the patterns for conditional + composable class strings.
Tailwind — class merging
EXAMPLE
// ===== The problem =====
// Building className strings with conditions:
const cls = ['btn', isActive ? 'btn-active' : '', size === 'lg' ? 'btn-lg' : ''].filter(Boolean).join(' ');
// Works but verbose. Conflicts (e.g. 'p-2' and 'p-4') win by order, not intent.
// ===== clsx (or classnames) =====
// npm install clsx
import clsx from 'clsx';
const cls = clsx(
'btn',
isActive && 'btn-active',
{ 'btn-lg': size === 'lg', 'btn-sm': size === 'sm' },
variant === 'danger' && 'bg-red-500',
);
// ===== tailwind-merge =====
// Resolves Tailwind class CONFLICTS by keeping the last one of a family.
// npm install tailwind-merge
import { twMerge } from 'tailwind-merge';
twMerge('p-2 p-4'); // 'p-4'
twMerge('text-red-500 text-blue-500'); // 'text-blue-500'
twMerge('bg-red-500 bg-red-700'); // 'bg-red-700'
// Combined with clsx:
const cn = (...args) => twMerge(clsx(...args));
// ===== cn helper (shadcn/ui style) =====
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
// Usage:
<button className={cn('px-4 py-2 rounded', isActive && 'bg-blue-500', className)}>
Save
</button>
// ===== cva (class-variance-authority) =====
// npm install class-variance-authority
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva('rounded px-4 py-2 font-semibold', {
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'bg-transparent text-slate-900 hover:bg-slate-100',
},
size: {
sm: 'text-sm py-1 px-3',
md: 'text-base py-2 px-4',
lg: 'text-lg py-3 px-6',
},
fullWidth: { true: 'w-full' },
},
compoundVariants: [
{ intent: 'primary', size: 'lg', class: 'shadow-lg' },
],
defaultVariants: { intent: 'primary', size: 'md' },
});
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;
function Button({ intent, size, fullWidth, className, ...rest }: ButtonProps) {
return <button className={cn(button({ intent, size, fullWidth }), className)} {...rest} />;
}
// Use:
<Button intent="danger" size="sm">Delete</Button>
// ===== Patterns =====
// - cn (clsx + twMerge) helper as the only way to build classes
// - cva for components with variants (buttons, badges, cards)
// - className prop on every component for parent overrides
// - twMerge handles conflicts so you don't think about order
// ===== Pitfalls =====
// - clsx alone -> conflicting Tailwind classes win by order (bug-prone)
// - twMerge has a cost; cache or use the precomputed config for huge apps
// - Spread-the-rest before className -> parent className overridden
// - Inline cva configs in render -> recreate every render
Why it matters
cn = clsx + tailwind-merge resolves conditionals and conflicts. cva gives you typed component variants. shadcn/ui showed the pattern: every component takes className, cn merges with parent overrides. The result is composable, type-safe styling with no class-order bugs.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
className={twMerge(clsx('px-4 py-2', danger && 'bg-red-600'))}
Try it Yourself »
Discussion
Loading…