Why it breaks
In CSS, when two utilities set the same property, the one defined later in the generated stylesheet wins, not the one later in the class attribute. bg-red-500 vs bg-blue-500 is therefore unpredictable from the component's point of view.
Solution: tailwind-merge
tailwind-merge understands Tailwind's classes and removes earlier conflicting ones. Combine it with clsx for conditionals.
npm install clsx tailwind-merge
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'ghost'
}
export function Button({ className, variant = 'primary', ...props }: Props) {
return (
<button
className={cn(
'rounded-md px-4 py-2 font-medium transition-colors',
variant === 'primary' && 'bg-blue-500 text-white hover:bg-blue-600',
variant === 'ghost' && 'bg-transparent hover:bg-gray-100',
className,
)}
{...props}
/>
)
}
<Button className="bg-red-500 px-6" /> now outputs ... bg-red-500 px-6 with the blue background and px-4 removed.
Notes
- Put the caller's
className last.
tailwind-merge v3 matches Tailwind 4; v2 targets Tailwind 3. Use the matching major version, otherwise new v4 utilities are not recognised.
- Custom theme tokens (such as
text-brand) may need extendTailwindMerge so it knows the group.
- For variant-heavy components,
class-variance-authority pairs well with cn.
Pitfall
twMerge runs at render time and has a small cost; it is fine for components but avoid it on thousands of list rows with static classes.