Composing classes with cn()
The cn() helper that every reusable React component in this course uses to merge Tailwind classes, built from clsx and tailwind-merge.
So far you’ve been a consumer of styles. You read utility strings, you defined the tokens underneath them, and every class you wrote landed on a tag you owned. This lesson flips the role: you write a component that other code styles. As soon as you do, a small but specific bug shows up, and a one-line helper called cn() becomes the function you reach for most often in your codebase.
The scenario is about as ordinary as front-end work gets. You build a <Button> once and reuse it across the app. Internally it sets px-4 so the label has breathing room. On one screen, a hero section, you want that button wider, so at the call site you pass className="px-8":
<Button className="px-8">Get started</Button>The naive way to honor that prop inside the component is to glue the consumer’s string onto the end of your own:
const Button = ({ className }: { className?: string }) => { return <button className={`inline-flex rounded-md bg-primary px-4 py-2 ${className}`}>...</button>;};The element now carries both px-4 and px-8, which raises the question this whole lesson answers: which padding wins, and can you rely on the answer? If you can’t, the component isn’t truly reusable, because it styles itself differently depending on details no consumer can see or control.
By the end of this lesson you’ll write the cn() helper yourself, understand the two distinct jobs it does, and apply the one ordering rule that makes overrides win every time: the consumer’s className goes last. It’s the exact shape every reusable component in this course is built on.
Two classes, one property: the bug behind the bug
Section titled “Two classes, one property: the bug behind the bug”cn() only makes sense once you’ve seen what it fixes, so let’s make the failure concrete.
Go back to that naive body. With the consumer passing px-8, the string your component hands to the DOM is inline-flex rounded-md bg-primary px-4 py-2 px-8. Both px-4 and px-8 are valid Tailwind utilities, both set the same CSS property (horizontal padding), and both end up in the element’s class attribute. The browser can’t apply both, so it has to pick one.
<Button className="px-8">Get started</Button>
const Button = ({ className }: { className?: string }) => { return <button className={`inline-flex rounded-md bg-primary px-4 py-2 ${className}`}>...</button>;};The consumer passes px-8; the component already set px-4. Concatenation only appends, gluing the consumer’s string onto the end. Nothing removes the old px-4.
<button class="inline-flex rounded-md bg-primary px-4 py-2 px-8">...</button>Both survive. Which one actually paints is decided by the cascade, not by the order of the classes in your string, and the cascade’s tiebreaker is set by Tailwind rather than by you.
So why can’t you just trust that the later class in the string wins? Because the order of classes in your class attribute has nothing to do with which one the browser applies. When two rules are equally specific, CSS breaks the tie through the cascade : the last one in the stylesheet wins. Tailwind emits its utilities into that stylesheet in its own fixed order, regardless of where the classes sit in your markup. So the winner is whichever of the px-4 and px-8 rules Tailwind happened to place later in the generated CSS. You don’t control that order, and you can’t work it out from your own code. The cascade’s mechanics come in a later chapter; here you only need the consequence.
That makes this bug hard to deal with for two reasons. It’s silent: there’s no error, because both classes are valid and nothing is technically wrong. And it’s unpredictable: the outcome can shift between Tailwind versions or builds, and the consumer who wrote px-8 has no way to look at their own code and tell whether it took effect. A component whose overrides you can’t reason about is, for all practical purposes, broken.
That points straight at the fix. Template-literal concatenation is the wrong tool for Tailwind classes for one structural reason: it can only append, never delete. To make an override work, you don’t need to add the new class. You need something to remove the losing one, so only the winner is left on the element. That deleting tool is tailwind-merge, and you’ll reach it through cn().
Before we get to the fix, try reproducing the bug yourself. The exercise below is meant to be frustrating.
This <Badge> builds its class string by concatenation, with bg-muted baked in. Pass className="bg-primary text-primary-foreground" and try to make the badge primary-colored to match the target. Notice how unreliable overriding the built-in bg-muted is — concatenation only appends, so it can't remove the conflicting class. Leave the theme <style> block alone.
clsx: deciding which classes are present
Section titled “clsx: deciding which classes are present”cn() does two jobs, and the cleanest way to understand it is to meet each one on its own. We’ll build it from the inside out, starting with the inner job.
The first job is deciding which classes are in the string at all. Real components don’t apply a fixed set of classes; they toggle some on and off. A button dims while it’s submitting; a tab gets a background when it’s active. You need a way to say “include this class, but only when this condition holds.” That’s clsx .
clsx is a tiny (~240 byte), dependency-free utility that takes a mix of inputs and joins the truthy ones into a single space-separated string. It accepts plain strings, arrays, and, most usefully, objects whose keys are kept only when their value is truthy. Anything falsy along the way (false, null, undefined, 0, '') is silently dropped. That’s its entire purpose: conditional assembly.
clsx('rounded-md', { 'opacity-50': isPending }, isActive && 'ring-2');This single call mixes all three input shapes: a base string that’s always present, an object whose key appears only when isPending, and a && expression that contributes ring-2 only when isActive.
The object form is worth getting comfortable with: the key is the class, the value is the condition. { 'opacity-50': isPending } means “apply opacity-50 when isPending is true.” It reads almost like English.
There’s one catch, and it sets up the rest of the lesson. clsx knows nothing about Tailwind. It doesn’t understand that px-4 and px-8 fight over the same property; to clsx they’re just two strings, and it keeps both:
clsx('px-4 px-8'); // -> 'px-4 px-8'So clsx is Tailwind-blind. It decides which classes are present, and it does that job perfectly, but it never deletes a conflict, because it can’t see one. Deleting conflicts is the second job.
tailwind-merge: deciding which conflicting class survives
Section titled “tailwind-merge: deciding which conflicting class survives”The second job is resolving conflicts, and for that you need a tool that reads the string the way Tailwind does. That’s tailwind-merge .
tailwind-merge parses a class string as Tailwind. It knows that px-4 and px-8 both control horizontal padding, groups them as a conflict, and within that group keeps only the last one, deleting the rest. Its exported function is twMerge:
twMerge('px-4 py-2 px-8'); // -> 'py-2 px-8'px-4 is gone. py-2 stays, because it controls a different property and never conflicted. The detail that matters is which one survives: the last of the conflicting classes. That single fact is the foundation of the override pattern you’re about to learn, because if the consumer’s class is last, the consumer wins.
tailwind-merge does more than a plain string match, which is what lets you trust it. It understands variant prefixes, so hover:bg-primary and hover:bg-accent conflict but bg-primary and hover:bg-accent don’t, because they apply in different states. It understands responsive prefixes the same way. It also understands shorthand-versus-longhand families. p-4 sets padding on all four sides and px-8 sets only the horizontal padding, so they overlap only partially; tailwind-merge keeps both and lets px-8 win on the horizontal axis alone rather than dropping either one:
twMerge('p-4 px-8'); // -> 'p-4 px-8'Let’s watch the two jobs run back to back on a real input. Scrub through the sequence below. It takes the raw arguments a component would pass, runs them through clsx, then through tailwind-merge, and shows you exactly what each step changes.
clsx flattens everything into one string and drops the falsy opacity-50, but it still carries both px-4 and px-8.
The last one wins: px-4 is deleted and the consumer’s px-8 is left standing, so the result is inline-flex rounded-md bg-primary py-2 px-8.
One caveat, stated once so you recognize it later: tailwind-merge’s conflict knowledge is tied to a specific Tailwind version, because it has to know which utilities exist to know which ones fight. So the installed tailwind-merge must match your Tailwind v4. A mismatch fails quietly, mis-resolving conflicts. You won’t hit this in practice, since the project scaffold pins compatible versions and current tailwind-merge supports v4, so the only thing to remember is not to bump one without the other.
Writing cn(): clsx then tailwind-merge
Section titled “Writing cn(): clsx then tailwind-merge”You now know both jobs. clsx decides which classes are present; tailwind-merge decides which conflicting one survives. Run them in that order and you have a function that flattens conditionals and then resolves conflicts in a single pass. That function is cn(), and its body is just the two halves nested:
import { type ClassValue, clsx } from 'clsx';import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}Read it from the inside out and it’s exactly the pipeline you just watched. clsx(inputs) runs first, flattening the conditionals and dropping the falsy values, and its flat string feeds twMerge second, which resolves the conflicts last-wins. The nesting order is the order of the two jobs.
Two things to take from this. First, the signature: ...inputs: ClassValue[] means cn() accepts any number of arguments, in every shape clsx accepts, because ClassValue is clsx’s own input type. So you can pass strings, conditionals, objects, and arrays, all in one call.
Second, and this is a rule rather than a preference: you don’t write this file. cn() lives at lib/utils.ts and ships in the project scaffold from the start. This is the convention every shadcn project follows, so every component in this course imports it from the same place rather than redefining it. The @/lib/utils alias gets you there from anywhere:
import { cn } from '@/lib/utils';One last thing, so you don’t over-think it: cn() runs while your component renders, and it costs a fraction of a millisecond per call. At the scale of a SaaS UI that cost never matters, so there’s no need to wrap it in useMemo or cache it. Just call it.
The override pattern: className last
Section titled “The override pattern: className last”This is the pattern you’ll reach for in nearly every reusable component you write. Here’s the <Button> from the start of the lesson, done correctly. Its props extend the native button’s and add one of its own, isPending, for a submitting state:
import type { ComponentProps } from 'react';import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'> & { isPending?: boolean };
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};Look at the three arguments to cn(), because their order is the whole point. The base classes come first. The conditional (opacity-50 while the button is submitting) comes in the middle. And the consumer’s className comes last.
That ordering is the central rule of the lesson: inside cn(), the consumer’s className is always the last argument. You already know why: tailwind-merge keeps the last conflicting class, so last position is exactly what lets a consumer’s px-8 delete the component’s px-4. Move className any earlier in the call and you flip the outcome. The component’s own defaults would land last and override the consumer, and overrides would silently stop working.
That closes the bug from the start of the lesson. <Button className="px-8"> now wins reliably, because cn deletes px-4 and keeps the later px-8, not by luck of emit order but deterministically, every build. Let’s walk the corrected version against the naive one, then step through the argument order.
const Button = ({ className, ...rest }: ButtonProps) => { return ( <button className={`inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground ${className}`} {...rest} /> );};Consumer override is unreliable. Both px-4 and the consumer’s px-8 land on the element, and the cascade picks the winner. Nothing deletes the loser.
const Button = ({ className, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', className, )} {...rest} /> );};className is the last cn() argument, so the consumer’s px-8 deletes px-4 and wins, deterministically, every build.
The argument order is the one place worth slowing down on, so here it is step by step.
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};Pull className and isPending out of props; ...rest collects everything else the consumer passed.
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};First argument: the component’s own defaults. Always present.
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};Middle argument: a conditional class. opacity-50 is added only while the button is submitting.
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};Last argument: the consumer’s override. Because tailwind-merge keeps the last conflicting class, last position is what lets the consumer win.
const Button = ({ className, isPending, ...rest }: ButtonProps) => { return ( <button className={cn( 'inline-flex items-center rounded-md bg-primary px-4 py-2 text-primary-foreground', isPending && 'opacity-50', className, )} {...rest} /> );};Spread the rest of the props so the consumer’s onClick, aria-*, type, and the rest pass straight through to the real <button>.
Two boundaries are worth setting while the pattern is fresh, so you don’t over-apply it.
Reach for cn() only when there’s something to merge: a conditional class, or a consumer className. A static, unconditional class string doesn’t need it. className="flex items-center gap-2" is fine as a plain string; wrapping it in cn() adds nothing. cn() earns its place when something conditional or overridable is in play.
cn() belongs on the render path. It computes a class string for your JSX, so it runs where the JSX is built, not inside an effect or other non-render code. (You’ll meet effects in a later chapter; for now just know cn() isn’t for them.) This also adds to the debugging habit from earlier in this chapter, asking “is the class even in the DOM?” There’s now one more thing to check: if a class you wrote isn’t showing up, cn() may have merged it out because a later conflicting class deleted it.
Let’s pin the rule down with one quick recognition check.
Click the argument that must be passed last to cn() so consumer overrides win.
cn( 'inline-flex rounded-md bg-primary px-4 py-2', isPending && 'opacity-50', className,);Conditional classes: the four forms
Section titled “Conditional classes: the four forms”You’ve already seen clsx accept strings, objects, and && expressions. Those are forms of the same idea, “include this class when this holds,” and clsx (and so cn()) accepts four of them. They all flow through the same flattening step, so the choice between them is purely about which reads cleanest at the call site. Here’s the quick reference.
cn('rounded-md px-3 py-2', isActive && 'bg-accent');Use for a single on/off class; this is the default. Reads cleanest when one class toggles on one boolean. The left side must be a real boolean (more on that just below).
cn('rounded-md px-3 py-2', { 'bg-accent': isActive, 'opacity-50': isPending });Use when several independent classes each gate on their own boolean. Keeps the conditions aligned and scannable in one block.
cn('transition-transform', isOpen ? 'rotate-180' : 'rotate-0');Use when the choice is genuinely two-sided: this or that, not present or absent. Picks one of two classes rather than toggling one.
cn(['rounded-md px-3 py-2', isActive && 'bg-accent']);Use when you’re assembling classes from separate pieces and want to group them. Rare; mostly for building a list up programmatically.
There’s exactly one thing to watch here, and it carries forward from earlier in this chapter. With the && form, the left side has to be a real boolean. isActive && 'bg-accent' is fine because isActive is either true or false. But if the left side is a number or a string, a falsy value leaks into the output: count && 'badge' returns 0 when count is 0, and now 0 is sitting in your class string. For values that aren’t already booleans, coerce them: Boolean(count) && 'badge', or value != null && '...' when you only mean to guard against null and undefined.
Beyond that one rule, the forms are interchangeable. Pick whichever reads clearest at the call site; they all behave identically.
Where the merge stops: cn()‘s blind spots
Section titled “Where the merge stops: cn()‘s blind spots”cn() is reliable, but it has limits, and it’s worth knowing exactly where its guarantee ends.
What tailwind-merge knows is Tailwind’s own surface: the built-in utilities, the variant and responsive prefixes, the shorthand and longhand groups, and arbitrary values that follow Tailwind’s conventions. Inside that surface, conflicts dedupe cleanly.
What it can’t see is anything outside that surface: hand-written CSS classes (card-legacy), classes from a third-party library (swiper-slide), and arbitrary-property forms that don’t map to a known utility group. tailwind-merge has no model for what CSS those set, so it can’t tell when two of them fight. Two such classes targeting the same property will both survive, and you’re back to the cascade deciding. The fix is the same discipline you’ve already been following: stay on the utility surface. Bespoke classes are a deliberate, named boundary, not your default.
There is one escape hatch, named here only so you recognize it if you meet it. If you ship your own custom utilities (the @utility directive you saw when configuring Tailwind in CSS) and need tailwind-merge to understand their conflicts, extendTailwindMerge teaches it new conflict groups. You’ll almost never reach for it, so knowing the name is enough for now.
Use the check below to turn the boundary into a recognition skill. For each class pair, decide whether tailwind-merge can dedupe it or whether both classes survive untouched.
For each class pair, decide whether tailwind-merge can resolve the conflict or whether both classes survive because it can't see them. Drag each item into the bucket it belongs to, then press Check.
px-4 px-8rounded-md rounded-lghover:bg-primary hover:bg-accentcard-legacy promo-cardswiper-slide swiper-slide-activeThat covers the core of the lesson. There are two things to carry out of it. First, cn() is clsx then tailwind-merge: flatten the conditionals, then resolve the conflicts, in that order. Second, the consumer’s className goes last so their overrides reliably win. Those two facts are the whole composition story for a single component.
There’s one more chapter to this, which you’ll meet later. When a component grows independent variants, a button that varies by size, style, and state all at once, the && and object conditionals start to multiply, and you’ll want a cleaner way to declare that whole matrix once. That’s what class-variance-authority (CVA) does, and it pairs directly with cn(): CVA picks the variant classes, and cn() merges in the override. Every shadcn primitive you’ll meet uses the two together. We’ll get there; for now, cn() and className-last are the foundation everything else builds on.
External resources
Section titled “External resources”The maintainer's own guide to merging a className prop into a component's defaults — the exact pattern this lesson teaches.
Reference for the conditional forms — string, object, array — that cn() accepts through clsx.
ByteGrad builds cn() from scratch — unpredictable conflict, twMerge, then clsx's object syntax — in 8 minutes.
Where the cn helper at @/lib/utils comes from — the convention every component in this course follows.