Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 055 / 100

The cn() helper: React & Next.js lessons from production

Merge Tailwind classes without conflicts.

Expona Admin8 min read

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:

tsx
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.

tsx
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:

tsx
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:

tsx
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:

tsx
<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:

tsx
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:

tsx
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:

tsx
<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.

tsx
// 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) and tailwind-merge (conflict resolution)
  • Use it in every component that accepts a className prop to let consumers override styles predictably
  • clsx builds clean conditional class strings; tailwind-merge understands 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
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles