The cn() helper
If you've worked with Tailwind CSS in component libraries, you've probably noticed a nagging problem: when a parent component passes a className prop to override styles, it conflicts with the base classes you've already defined. The cn() helper solves this elegantly by merging Tailwind classes without conflicts, ensuring that caller-provided styles safely override defaults. Over 5+ years building production platforms, I've made this tiny utility a non-negotiable part of every React and Next.js project.
clsx handles conditional classes
The foundation of the cn() helper is clsx, a small library for building className strings conditionally. It lets you write clean, readable logic without string concatenation or ternaries scattered everywhere.
Consider a Button component that changes appearance based on variant and disabled state:
import { clsx } from 'clsx';
export const Button = ({ variant = 'primary', disabled, className }) => {
return (
<button
className={clsx(
'px-4 py-2 rounded font-semibold transition',
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-gray-200 text-gray-800 hover:bg-gray-300': variant === 'secondary',
'opacity-50 cursor-not-allowed': disabled,
},
className
)}
disabled={disabled}
>
Click me
</button>
);
};This works fine until you try to override the text color. If you pass className="text-red-500", Tailwind's cascade means both text-white (from the primary variant) and text-red-500 (from props) get included in the final CSS, and specificity determines the winner—unpredictably. I've seen this exact issue in three different projects: a ride-hailing platform's notification badges, an e-commerce site's product badges, and a SaaS dashboard's alert components. Each time, QA caught color inconsistencies that were impossible to debug without understanding this interaction.
The real-world scenario goes like this: your design team asks for a destructive button variant with red text. You add 'text-red-600': variant === 'destructive' to your component. Weeks later, a consumer passes className="text-white" expecting a different look. Both classes end up in the DOM. Tailwind outputs them in alphabetical order, so text-red-600 wins, frustrating the caller who has no idea why their override didn't work.
That's where clsx alone falls short. It just concatenates strings; it doesn't understand Tailwind semantics.
tailwind-merge resolves conflicting utilities
tailwind-merge is a brilliant library that understands Tailwind's class structure. It knows that text-white and text-red-500 both set text color, so the latter should win. It handles responsive variants, dark mode, arbitrary values—everything Tailwind throws at it.
import { twMerge } from 'tailwind-merge';
const baseClasses = 'px-4 py-2 rounded text-white bg-blue-600';
const overrides = 'text-red-500 bg-green-600';
const merged = twMerge(baseClasses, overrides);
// Result: 'px-4 py-2 rounded text-red-500 bg-green-600'Notice that spacing and border-radius are preserved because they don't conflict. Text and background colors from overrides replace the base classes entirely. This is exactly what you want in component APIs.
In real production code at one of my e-commerce platforms, we use this pattern across 80+ reusable components. Without tailwind-merge, customization requests would mean forking components or adding dozens of boolean props. I watched our design system grow from 12 components to over 80 in two years, and cn() made that growth manageable. Every single component uses it consistently.
An edge case worth knowing: tailwind-merge handles responsive prefixes correctly. If you pass md:text-red-500 as an override and the base class has text-white, the merge works because they operate on different breakpoints. But if both are md:text-white and md:text-red-500, the red wins. This is crucial when building responsive components where callers want different styles at different screen sizes.
Let callers override styles safely
Now combine both libraries into a single, reusable helper:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...c: ClassValue[]) =>
twMerge(clsx(c));This 3-line utility becomes the backbone of your design system. Let's see it in action:
import { cn } from '@/lib/cn';
export const Card = ({ variant = 'default', className, children }) => {
return (
<div
className={cn(
'rounded-lg border shadow-sm p-4',
{
'bg-white border-gray-200': variant === 'default',
'bg-blue-50 border-blue-200': variant === 'highlight',
'bg-red-50 border-red-200': variant === 'error',
},
className
)}
>
{children}
</div>
);
};Now a consumer can do:
<Card
variant="highlight"
className="bg-yellow-50 border-yellow-300 p-6"
>
Custom styling wins here.
</Card>The yellow background and border from className override the blue defaults. Padding goes from 4 to 6. The shadow and rounded corners remain. Perfect component composition.
This pattern is essential when building shared component libraries for teams. In the CRM platform I built, our design system exports 40+ components, all using cn(). Without it, we'd have either rigid components or unmaintainable prop combinations. I've had team members override Button padding for a specific page layout, Input border colors for validation states, and Modal backgrounds for dark mode experiments—all without touching the component code.
A concrete example from our travel booking platform: the FilterPanel component accepts a className prop. Consumers needed different background colors for different pages, responsive padding adjustments, and custom border treatments. With cn(), we defined sensible defaults once and let consumers override just what they needed:
export const FilterPanel = ({ className, filters }) => {
return (
<aside
className={cn(
'w-64 bg-white border-r border-gray-200 p-4 overflow-y-auto',
'md:w-80 md:p-6',
className
)}
>
{/* filters */}
</aside>
);
};Then in one route, they'd pass className="bg-gray-50 md:w-96" and everything just worked. The responsive width increased from 80 to 96 units on medium screens, the background changed, but the border and padding logic remained intact.
A tiny helper used everywhere
The cn() helper is so small and useful that it becomes ubiquitous. Every component that accepts styling gets it. It's typically stored in lib/cn.ts or utils/cn.ts and imported across your codebase thousands of times.
Here's where it shows up in real projects:
- UI Components: Button, Card, Input, Modal, Dropdown—anything accepting
className - Layout wrappers: Container, Stack, Grid components that might need responsive tweaks
- Compound components: Parts of complex elements like Tabs or Accordion that expose styling hooks
- Conditional rendering: Elements that show/hide based on state and need merged base + conditional classes
- Dynamic lists: When rendering arrays of items with per-item class overrides
In my trading platform's order form, we have 15+ input fields. Each uses cn() to merge validation state classes with caller customizations. One field needs smaller text for the trading pair selector, another needs a wider input for order quantity. Without cn(), we'd duplicate or fork the Input component repeatedly:
export const Input = ({ error, disabled, className, ...props }) => {
return (
<input
className={cn(
'px-3 py-2 border rounded transition',
{
'border-red-500 bg-red-50': error,
'border-gray-300': !error,
'opacity-50 cursor-not-allowed': disabled,
},
className
)}
disabled={disabled}
{...props}
/>
);
};Then the order form uses it like this:
<Input
label="Quantity"
error={errors.quantity}
className="text-lg"
value={quantity}
onChange={handleQuantity}
/>
<Input
label="Price"
error={errors.price}
className="w-32"
value={price}
onChange={handlePrice}
/>Each field gets its custom sizing while inheriting validation and disabled state styling automatically.
Common mistakes and edge cases
Passing clsx/merge in wrong order: Always call clsx() first to build the string, then twMerge() to resolve conflicts. Reversing this breaks the merging logic because twMerge() expects Tailwind class strings as input. I've seen developers write clsx(twMerge(c)) thinking it doesn't matter. It does. The order is fundamental.
Forgetting to twMerge at all: Some teams use only clsx() for "simplicity." Then wonder why caller overrides don't work. Using cn() throughout is the only solution that scales. I reviewed code from a colleague who tried to avoid the dependency and just used clsx. Within two sprints, they had 20+ overrides failing silently in production.
Overmerging in design tokens: If you're building a variant-heavy component with 5+ theme options, don't pass all combinations to cn(). Use clsx() conditionals to pick one variant group before passing to cn(). This keeps the merge surface small and predictable.
// Good: pick variant, then pass caller overrides to cn()
const variantClasses = {
primary: 'bg-blue-600 text-white',
secondary: 'bg-gray-200 text-gray-800',
};
return (
<button
className={cn(
'px-4 py-2 rounded',
variantClasses[variant],
className
)}
>
Label
</button>
);
// Avoid: passing multiple variant candidates to cn()
return (
<button
className={cn(
'px-4 py-2 rounded',
'bg-blue-600 text-white',
'bg-gray-200 text-gray-800',
className
)}
>
Bad
</button>
);
Mixing arbitrary values carelessly: Tailwind-merge handles arbitrary values, but don't write className="w-[200px]" when a standard class exists. Arbitrary values work but reduce autocomplete and increase bundle size slightly. In the WebSocket-driven dashboard I built, we had performance constraints. Every kilobyte of CSS mattered. Staying with standard Tailwind classes where possible kept the vendor bundle lean.
Performance with many merges: Calling cn() thousands of times per render is fine—it's optimized. But if you're rendering 500 list items each with cn(), ensure you're memoizing the component or the className string to avoid recalculation on every render. This is a React performance issue, not a cn() issue, but it's worth knowing.
Dark mode and plugins: If you use custom Tailwind plugins or dark mode variants, test tailwind-merge with them first. The library handles standard Tailwind, but unusual custom utilities might not merge predictably. In one SaaS product, we added a custom @applied rule that broke merging. The solution was to avoid passing custom utilities through cn() and apply them separately.
Key takeaways
- The cn() helper merges Tailwind classes safely by combining
clsx(conditionals) andtailwind-merge(conflict resolution) - Use it in every component that accepts a
classNameprop to let consumers override styles predictably clsxbuilds clean conditional class strings;tailwind-mergeunderstands Tailwind semantics and removes conflicts- Put
cn()in a shared utilities folder and import it everywhere—it's the foundation of maintainable component libraries - Common pitfalls: forgetting
twMerge, reversing the order, overcomplicating variant logic before merging, or mixing arbitrary values unnecessarily - Edge cases matter: responsive variants, dark mode, custom plugins, and large lists all have subtle behavior worth testing
- At scale (40+ components in production),
cn()eliminates 90% of "how do I let callers customize this?" headaches - The pattern scales from small teams to large design systems—I've used it consistently across e-commerce, CRM, trading, and SaaS platforms






Comments
Loading comments…