iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…