C.W.K.
Stream
Lesson 04 of 07 · published

tailwind-merge + clsx — The cn() Helper

~11 min · tailwind-merge, clsx, cn, className

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Every React component library has a tiny cn() helper. It's the same two lines everywhere. Knowing why it exists saves you from inventing your own broken version.

The two problems cn() solves

  1. Conditional classes — you want className="px-4 py-2 disabled:opacity-50" sometimes, plus extra classes only when a prop is set. Naive template literals get ugly fast.
  2. Caller overrides — your Button sets className="px-4" internally, the caller passes className="px-6", both end up in the DOM as px-4 px-6, and Tailwind's cascade picks whichever loaded second. Caller intent is silently lost.

The two libraries

  • clsx — handles conditional and array/object class composition. Takes any combination of strings, arrays, and { [class]: condition } objects and returns a clean space-separated string.
  • tailwind-merge — knows Tailwind's grammar. When you give it two conflicting utilities (px-4 and px-6), it keeps the last one.

Combine them and you get cn() — the canonical helper.

Where you'd write cn()

Once, in src/lib/cn.ts. Every component imports it. cwkPippa's tailwind-merge in package.json is for exactly this — a single cn() used across the chat UI, sidebar, council, admin panels, and everywhere else.

If you're concatenating class strings with + or template literals, you're reinventing cn() badly. The two functions are tiny, well-tested, and zero-dependency between them. Install both and move on.

Code

The canonical cn.ts file·ts
// src/lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}
Component using cn() for conditional + overridable classes·tsx
import { cn } from "@/lib/cn";

type ButtonProps = {
  variant?: "primary" | "ghost";
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  className?: string;
  children: React.ReactNode;
};

export function Button({ variant = "primary", size = "md", disabled, className, children }: ButtonProps) {
  return (
    <button
      disabled={disabled}
      className={cn(
        // Base — always applied
        "inline-flex items-center justify-center rounded-lg font-medium transition-colors",
        // Variant — picks one
        {
          "bg-brand text-bg hover:bg-brand-strong": variant === "primary",
          "bg-transparent text-fg hover:bg-bg-elevated": variant === "ghost",
        },
        // Size — picks one
        {
          "px-3 py-1.5 text-sm": size === "sm",
          "px-4 py-2 text-base": size === "md",
          "px-6 py-3 text-lg": size === "lg",
        },
        // Disabled state
        disabled && "opacity-50 cursor-not-allowed",
        // Caller override — tailwind-merge ensures conflicting utilities resolve correctly
        className
      )}
    >
      {children}
    </button>
  );
}

// Caller can override padding without fighting:
// <Button size="md" className="px-8">Bigger</Button>
// → tailwind-merge drops px-4 (from size=md), keeps px-8.

External links

Exercise

Install clsx and tailwind-merge in your bootstrap project. Create src/lib/cn.ts. Refactor any existing component that takes a className prop to use cn(internalClasses, className). Test by passing conflicting Tailwind utilities from the caller and verifying the caller wins.
Hint
Test case: a <Card className="bg-red-500"> should render with the red background even if Card internally sets bg-bg-elevated. Without cn(), both classes are on the element and the cascade picks one. With cn(), tailwind-merge keeps the caller's.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.