tw-merge + clsx: Conditional Classes Without Specificity Bugs
Stop fighting Tailwind specificity bugs. Learn how twMerge and clsx work together to handle conditional classes cleanly in any React component.
The Problem with Naive Tailwind Class Merging
Honestly, the most common source of silent bugs in Tailwind projects isn't missing utilities — it's class collisions you never see coming. You pass p-4 as a default and p-2 as an override prop. Both end up in the DOM. Tailwind doesn't care which one you intended. The browser picks based on order in the generated stylesheet, not the order in your JSX string.
This catches developers off guard because it looks fine in isolation. Your component renders. The class names are there. But the wrong one wins, and you spend twenty minutes in DevTools wondering why your padding isn't changing.
The fix isn't complicated, but it requires two tools working together: clsx for conditional logic and tailwind-merge for conflict resolution. Once you understand what each one does, you'll wonder how you shipped anything without them.
What clsx Actually Does (and Doesn't Do)
clsx is a tiny utility — 239 bytes minified — that takes any combination of strings, objects, and arrays, filters out falsy values, and joins everything into a single class string. That's it. It doesn't know anything about CSS. It doesn't resolve conflicts. It just concatenates.
The object syntax is where it shines. You pass { 'bg-red-500': isError, 'bg-green-500': isSuccess } and clsx handles the truthiness check for you. No more ternary chains that stretch across three lines and make your reviewers cry. It also handles undefined and null gracefully, which means you don't need defensive guards around every optional prop.
What it can't do: if isError and isSuccess are both true (shouldn't happen, but it does), you'll get both bg-red-500 and bg-green-500 in the output. clsx doesn't know these classes conflict. That's tailwind-merge's job.
What tailwind-merge Does That clsx Can't
tailwind-merge (v2.3.0 at time of writing) parses Tailwind class names and understands which ones target the same CSS property. When it sees p-4 p-2, it knows both set padding, so it drops p-4 and keeps p-2 — the last one wins, mirroring how you'd expect overrides to work. Same deal with text-red-500 text-blue-700, rounded rounded-lg, or w-full w-1/2.
The library ships with a complete map of Tailwind v4.0.2 utilities and their conflict groups. Arbitrary values work too — p-[8px] conflicts with p-4 just as you'd expect. It even handles modifiers correctly: hover:bg-red-500 and hover:bg-blue-500 conflict with each other, but not with bg-red-500.
The one thing to watch: if you're using custom Tailwind plugins or non-standard class names, you'll need to extend the merge config. The extendTailwindMerge function handles this and it's straightforward to set up.
The cn() Pattern: Combining Both Libraries
The standard pattern across the ecosystem is a small helper function called cn (short for class names). You'll see it in shadcn/ui, Radix-based design systems, and pretty much every serious Tailwind component library. Here's the canonical version:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage in a component
interface ButtonProps {
variant?: 'primary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
children: React.ReactNode;
}
export function Button({ variant = 'primary', size = 'md', className, children }: ButtonProps) {
return (
<button
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
// Variant styles
{
'bg-violet-600 text-white hover:bg-violet-700': variant === 'primary',
'bg-transparent border border-white/20 text-white hover:bg-white/10': variant === 'ghost',
},
// Size styles
{
'h-8 px-3 text-xs': size === 'sm',
'h-10 px-4 text-sm': size === 'md',
'h-12 px-6 text-base': size === 'lg',
},
// Consumer override — wins against everything above
className
)}
>
{children}
</button>
);
}The key thing here: className comes last. tailwind-merge gives priority to later classes in the string, so any override a consumer passes will correctly beat the component's defaults. This is the pattern that makes components actually composable. You can check out how Tailwind component patterns take this further with more complex variant systems.
Specificity Bugs in Tailwind v4 and Why They're Sneakier
Tailwind v4 switched to a CSS-first config model and changed how it generates the stylesheet. The order utilities appear in the output file no longer matches the order you'd naively expect from the config. If you're migrating from v3 and wondering why some overrides stopped working, this is probably why. The Tailwind v4 features breakdown covers the full scope of what changed.
The specificity model in v4 is actually more consistent — all utilities have specificity 0,0,1 when using the default layer approach. But that means class order in the generated CSS is the only tiebreaker, and that order is determined by the utility's position in Tailwind's internal registry, not by your JSX. So px-4 px-2 might always render as px-4 regardless of which one you wrote second. tailwind-merge sidesteps this entire issue by resolving conflicts before they hit the DOM.
Have you ever opened DevTools and seen a crossed-out utility that should have won? That's the specificity trap in action. tailwind-merge means you'll see that much less often.
Performance Considerations and Caching
tailwind-merge is not free at runtime. It parses class strings, runs them through conflict resolution logic, and returns a new string. For most components this is negligible — we're talking microseconds. But if you're rendering lists of thousands of items with complex class logic, it adds up.
The library includes built-in LRU caching (512 entries by default) for repeated class string inputs. If your component consistently receives the same set of classes, the cache hit rate will be high and the overhead drops to almost nothing. You can tune the cache size with createTailwindMerge if you have unusual patterns.
For static class combinations that never change, you can call cn() outside the component body — at module level — and just reference the result string. This is especially useful for utility classes on wrapper divs or containers that don't vary. It's a small optimization but it keeps the intent clear too.
Dark Mode and Theme Tokens with cn()
Dark mode modifiers work correctly with tailwind-merge. dark:bg-gray-900 and dark:bg-slate-800 are treated as conflicting within the dark: variant group, so the expected one wins. This matters a lot if you're building a theme toggle in React where components receive different class sets based on the active theme.
One pattern worth knowing: when you're working with CSS custom properties and Tailwind's oklch color utilities — which you might be doing if you've read about Tailwind oklch colors — arbitrary class names like bg-[oklch(0.65_0.2_270)] and bg-[rgba(255,255,255,0.15)] are handled correctly. tailwind-merge treats arbitrary values in the same utility group as conflicts, so bg-[#ff0000] will correctly override bg-red-500.
The cn() helper also plays well with glassmorphism setups. If you're stacking backdrop filters, opacity values, and border utilities, having clean conflict resolution means your override system works predictably. The advanced glassmorphism techniques article shows real patterns where this matters.
When You Don't Need tailwind-merge
Not every component needs both libraries. If your class list is fully static — no prop-driven variants, no consumer className override — you don't need either one. Just write the string. Adding cn() to everything is a habit that adds noise without benefit in those cases.
clsx alone is enough when you have conditional classes that don't conflict. A toggle that adds opacity-50 cursor-not-allowed when disabled, for example. Those utilities don't conflict with anything on a typical button, so you don't need merge resolution. Use clsx for the conditional logic and skip the overhead.
tailwind-merge becomes necessary the moment you have a component that accepts a className prop for external overrides, or when you're building variant systems where two variants might accidentally activate the same CSS property. That's the line. If you're building a shared component library — even a small internal one — you almost certainly need both.
FAQ
Yes. tailwind-merge v2.3.0+ includes support for Tailwind v4.0.2 utility groups. If you're on an older version of tailwind-merge with a v4 project, upgrade — the conflict maps changed and you'll get incorrect results with the old version.
They do different things. clsx handles conditional logic — turning objects and arrays into a class string. tailwind-merge handles conflict resolution — ensuring only one class per CSS property survives. You need both for a complete solution. The cn() pattern composes them in one call so you only import one helper in your components.
Custom plugin utilities are unknown to tailwind-merge by default, so it won't resolve conflicts between them — but it also won't break anything. It'll just pass them through as-is. Use extendTailwindMerge() to register your custom utility groups and conflict rules.
There's a small runtime cost for parsing and merging. For typical component trees it's imperceptible. tailwind-merge has a built-in LRU cache that makes repeated calls with the same inputs very fast. If you're rendering thousands of identical items in a list, consider memoizing the class string outside the render cycle.
Yes, and it matters a lot. tailwind-merge gives priority to classes that appear later in the string. Always put consumer overrides (the className prop) last so they win over the component's internal defaults. Getting this order wrong is the most common cn() mistake.
tailwind-merge only knows about Tailwind utility classes. It'll pass non-Tailwind class names through without touching them — but it won't resolve conflicts between custom CSS module classes. For mixed setups, clsx handles the concatenation fine; tailwind-merge just won't help with non-Tailwind conflicts.