EmpireUI
Get Pro
← Blog8 min read#clsx#cn()#tailwind

clsx and cn() in Tailwind Projects: Conditional Classes Without Chaos

Learn how clsx and the cn() utility keep Tailwind class logic readable, type-safe, and free from string-interpolation nightmares in real React projects.

Code editor screen showing colorful JavaScript and Tailwind class strings

Why Conditional Tailwind Classes Turn Into a Mess

If you've been writing Tailwind for more than a week, you've hit the moment. You're passing a disabled prop into a button component and you write something like this: ` bg-blue-500 ${disabled ? 'opacity-50 cursor-not-allowed' : ''} hover:bg-blue-600 . It works. You ship it. Three weeks later someone adds an isLoading state, a variant prop, and a size` prop, and that template literal is now a 200-character sprawl that nobody wants to touch.

This isn't a skill issue — it's just what raw string interpolation does when your styling has branches. Tailwind's utility-first model is incredible for iteration speed, but it doesn't come with a built-in answer to conditional logic. That's not a criticism; it's just where clsx comes in.

The whole ecosystem basically landed on clsx (and the cn() wrapper you'll find in shadcn, Empire UI, and a hundred other component libraries) as the standard solution. It's tiny, fast, and type-safe when you're working in TypeScript. Once you start using it you won't go back.

What clsx Actually Does (It's Not Magic)

clsx is a utility by Luke Edwards that merges class strings from a mix of strings, objects, and arrays — and it filters out falsy values automatically. The whole library is around 300 bytes minified. That's it.

npm install clsx
# or if you're on pnpm
pnpm add clsx

Here's the core API in one shot: ``ts import clsx from 'clsx'; clsx('foo', 'bar'); // 'foo bar' clsx('foo', undefined, null, false); // 'foo' clsx({ 'font-bold': true, 'italic': false }); // 'font-bold' clsx(['text-sm', 'leading-tight']); // 'text-sm leading-tight' ``

You can mix all those forms in a single call. That's what makes it so useful for components with multiple conditional branches. In practice, you end up writing stuff like this: ``tsx import clsx from 'clsx'; type ButtonProps = { variant: 'primary' | 'ghost'; size: 'sm' | 'md' | 'lg'; disabled?: boolean; }; function Button({ variant, size, disabled }: ButtonProps) { return ( <button className={clsx( 'rounded font-medium transition-colors', { 'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary', 'bg-transparent border border-neutral-300 hover:bg-neutral-100': variant === 'ghost', }, { '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 && 'opacity-50 cursor-not-allowed pointer-events-none' )} disabled={disabled} > Click me </button> ); } ``

Worth noting: the object syntax is the part people underuse. Passing { 'some-class': condition } reads way more clearly than a ternary that returns an empty string, and it scales better when you have three or four conditions stacking up.

The cn() Wrapper — Why Every Library Ships One

If you've used shadcn/ui or looked at the Empire UI's component source, you've seen this pattern: ``ts import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``

Two packages working together. clsx handles all the conditional logic and filters falsy values. tailwind-merge handles a separate problem: Tailwind class conflicts. If you're composing components and you pass p-4 as a base class but then want to override it with p-8 via a prop, plain clsx will just concatenate both — and CSS specificity decides the winner, which is almost always "the first one wins because they're both from the same utility layer." That's a silent bug.

tailwind-merge (maintained since 2022, now on v3.x) understands Tailwind's semantic groups. It knows that p-4 and p-8 conflict, and it keeps the last one. Same for text-red-500 vs text-blue-700, rounded vs rounded-full, and every other group. The combination of clsx + twMerge is honestly the most practical decision shadcn ever popularized.

Install both: ``bash npm install clsx tailwind-merge ` Then create a lib/utils.ts (or wherever your project drops shared utilities): `ts // lib/utils.ts import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } `` You'll import this in basically every component you write. It's a one-time setup.

Honestly, if you're not using tailwind-merge yet, go add it right now. Especially if you're building reusable components that accept a className prop — overriding base classes without it is a consistent source of subtle styling bugs that only show up in edge cases.

Patterns That Actually Come Up Day-to-Day

Let's skip the toy examples and talk about the patterns you'll actually reach for. The first big one is the className passthrough pattern — any component that accepts external classes needs cn() to handle conflicts: ``tsx function Card({ className, children }: { className?: string; children: React.ReactNode }) { return ( <div className={cn('rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm', className)}> {children} </div> ); } // Consumer can override safely: <Card className="border-blue-500 p-8">...</Card> // Result: rounded-2xl bg-white shadow-sm border-blue-500 p-8 // (border and p- conflicts resolved, blue wins, p-8 wins) ``

The second pattern is variant maps. Instead of a chain of ternaries, define a record and index into it: ``ts const variantClasses: Record<string, string> = { success: 'bg-green-100 text-green-800 border-green-200', error: 'bg-red-100 text-red-800 border-red-200', warning: 'bg-yellow-100 text-yellow-800 border-yellow-200', info: 'bg-blue-100 text-blue-800 border-blue-200', }; function Alert({ variant, message }: { variant: keyof typeof variantClasses; message: string }) { return ( <div className={cn('rounded-lg border px-4 py-3 text-sm', variantClasses[variant])}> {message} </div> ); } ``

Quick aside: if you want compile-time safety on variant keys, Zod or a TypeScript literal union on the prop type is cleaner than a plain string key. The pattern above works but won't catch typos like 'succes' until runtime.

One more thing — the array syntax in clsx is handy when you're spreading computed class arrays or when a utility function returns string[]. You can nest arrays arbitrarily deep and clsx flattens them: ``ts function getStateClasses(state: 'idle' | 'loading' | 'error') { if (state === 'loading') return ['animate-pulse', 'pointer-events-none']; if (state === 'error') return ['border-red-500', 'text-red-600']; return []; } const classes = cn('base-class', getStateClasses(state), isSelected && 'ring-2 ring-blue-500'); ``

This composes really cleanly — each concern stays in its own function, and cn() assembles the final string at the edge.

TypeScript Tips: Getting Type Safety Out of ClassValue

clsx ships a ClassValue type that covers everything the function accepts: strings, numbers, booleans, null, undefined, arrays, and objects. Importing and using it in your own utilities keeps things tight: ``ts import { type ClassValue } from 'clsx'; // Your own utility that wraps cn() with extra logic function buttonCn(base: ClassValue, ...overrides: ClassValue[]) { return cn(base, ...overrides); } ``

If you're building a component library (not just an app), you might want to export cn itself so consumers don't need to install clsx separately: ``ts // In your library's public API export { cn } from './lib/utils'; ``

Look, TypeScript won't validate that you're passing valid Tailwind class names — it's just strings. For that level of checking, tools like eslint-plugin-tailwindcss with the no-contradicting-classname rule get you closer. That plugin also works with clsx and cn() calls as of version 3.14.0, which is worth enabling in any serious project.

One gotcha with dynamic class names in Tailwind: the purge/content scanner in Tailwind v3 and v4 looks for complete class name strings in your source. If you construct class names dynamically like ` text-${color}-500 `, the scanner won't see those and they'll be stripped from your production bundle. Always prefer the full class name in your source, even if it means a longer object map. This catches people constantly and it's not obvious until you notice missing styles in prod.

clsx vs classnames vs cva — When to Reach for What

You might also have seen classnames — the older package that clsx is essentially a faster, smaller drop-in replacement for. If you're starting a new project, use clsx. It's 3x smaller and benchmarks faster. The API is compatible enough that you can swap them by changing one import.

Then there's cva (Class Variance Authority), which operates at a higher level. Instead of just merging strings, cva gives you a full variant definition system with TypeScript inference: ``ts import { cva } from 'class-variance-authority'; const button = cva('rounded font-medium transition-colors', { variants: { intent: { primary: 'bg-blue-600 text-white hover:bg-blue-700', ghost: 'border border-neutral-300 hover:bg-neutral-100', }, size: { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-base', }, }, defaultVariants: { intent: 'primary', size: 'md', }, }); button({ intent: 'ghost', size: 'sm' }); // 'rounded font-medium transition-colors border border-neutral-300 hover:bg-neutral-100 px-3 py-1.5 text-sm' ``

In practice, for application code, clsx + cn() is all you need. cva shines when you're building a full design system where you want TypeScript to infer what variants are valid and catch wrong values at compile time. It's more setup but pays off at scale. The Empire UI's component architecture uses this kind of pattern — if you look at styles like the glassmorphism components, you'll see variant-driven class composition throughout.

That said, don't reach for cva too early. If you have two variants and five components, clsx with a record object is simpler and easier for other devs to read. Optimize when the complexity actually shows up.

One more thing — tailwind-merge works with cva too. You'd still wrap in cn() if you need consumer-level class overrides. The stack is cva for variant definitions, clsx for conditional merging, twMerge for conflict resolution. Each layer has a single job.

Putting It All Together in a Real Component

Let's build something you'd actually ship — a Badge component with size, variant, and external className support. This hits every pattern we've covered: ``tsx import { cn } from '@/lib/utils'; import { cva, type VariantProps } from 'class-variance-authority'; const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors', { variants: { variant: { default: 'border-transparent bg-neutral-900 text-white hover:bg-neutral-800', secondary: 'border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-200', success: 'border-transparent bg-green-500 text-white', destructive: 'border-transparent bg-red-500 text-white', outline: 'border-neutral-300 text-neutral-700', }, }, defaultVariants: { variant: 'default', }, } ); type BadgeProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>; function Badge({ className, variant, ...props }: BadgeProps) { return ( <div className={cn(badgeVariants({ variant }), className)} {...props} /> ); } export { Badge, badgeVariants }; ``

Three things happening here: cva defines the variant structure with full TypeScript inference, cn() merges the variant output with any consumer-provided className, and twMerge (inside cn) handles any conflicts between the base classes and overrides. You get a component where <Badge variant="success" className="text-sm"> just works — even the font size override resolves correctly.

For complex UI systems, this pattern scales to 20-30 components without getting painful. The key is keeping cn() at the boundary — base classes defined with cva, external overrides merged at render time. If you're pulling inspiration from design systems like Empire UI's glassmorphism components or checking out the tools section for design tokens, you'll see this kind of disciplined class composition makes the difference between a component that's easy to extend and one that fights you every time.

Quick aside: TypeScript strict mode ("strict": true in your tsconfig) will catch cases where you accidentally pass undefined into a non-optional variant. Set it up from the start — retrofitting strict mode into an existing project with 200 components is not a fun afternoon.

FAQ

What's the difference between clsx and the cn() function?

clsx is the npm package that handles conditional class merging. cn() is a wrapper that combines clsx with tailwind-merge, so it also resolves Tailwind class conflicts (like p-4 vs p-8). You need both for component libraries where consumers pass className overrides.

Will clsx cause Tailwind to strip classes in production builds?

clsx itself doesn't cause purging issues — but dynamically constructing class names does. If you write text-${color}-500 anywhere, Tailwind's scanner can't see the full class name. Always write complete class strings like 'text-red-500' in your source, even inside clsx calls.

Do I need cva if I'm already using clsx?

Not necessarily. clsx with a variant record object handles most app-level use cases cleanly. cva is worth adding when you're building a reusable component library and want TypeScript to infer valid variant values at compile time. It's more setup for more type safety.

Can I use cn() with Tailwind v4?

Yes. tailwind-merge v3.x supports Tailwind v4's updated class groups, including the new color system and other v4-specific utilities. Just make sure you're on tailwind-merge v3+ and the cn() pattern works identically to v3.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

tw-merge + clsx: Conditional Classes Without Specificity Bugstailwind-merge: Resolve Conflicting Tailwind Classes SafelyReact UI Components Complete Reference: 60+ Patterns with CodeBuilding Design Systems That Scale: Engineering Guide 2026