Composing classes with cn()
The cn() helper 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: every class you wrote landed on a tag you owned. This lesson flips the role. You write a component that other code styles, and as soon as you do, a specific bug appears. The fix is a one-line helper called cn().
Take a <Button> you reuse across the app. Internally it sets px-4. On a hero section you want it wider, so the call site passes className="px-8":
<Button className="px-8">Get started</Button>The naive way to honor that prop 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 padding wins, and can you rely on the answer? If you can’t, the component isn’t truly reusable: it styles itself differently depending on details no consumer can see or control.
By the end you’ll write cn() yourself and apply the one rule that makes overrides win every time: the consumer’s className goes last. It’s the 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”Go back to that naive body. When the consumer passes 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 utilities, both set horizontal padding, and both land in the class attribute. The browser can’t apply both, so it picks 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. 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 paints is decided by the cascade, not by their order in your string.
You might expect the later class in the string to win, but the order of classes in the class attribute has nothing to do with which one applies. When two rules are equally specific, CSS breaks the tie through the cascade : the last rule in the stylesheet wins. Tailwind emits its utilities into that stylesheet in its own fixed order, so the winner is whichever of px-4 and px-8 it placed later. You don’t control that order and can’t work it out from your code. The cascade’s mechanics come in a later chapter; here you need only the consequence.
That makes the bug hard to deal with. It’s silent: both classes are valid, so nothing errors. And it’s unpredictable: the outcome can shift between Tailwind versions or builds, and the consumer who wrote px-8 can’t tell from their own code whether it took effect.
The fix follows from the cause. Concatenation can only append, never delete, but an override needs the losing class removed so only the winner is left. 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”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 joins its truthy inputs into one 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 dropped.
clsx('rounded-md', { 'opacity-50': isPending }, isActive && 'ring-2');This call mixes all three 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.”
But 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 does that perfectly, but it never deletes a conflict, because it can’t see one. Deleting conflicts is the second job.
tailwind-merge: the last conflicting class wins
Section titled “tailwind-merge: the last conflicting class wins”Resolving conflicts needs a tool that reads the string the way Tailwind does: tailwind-merge.
Because it parses each class as Tailwind, it knows px-4 and px-8 both set horizontal padding, groups them as a conflict, and keeps the last one. 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 never conflicted. Which one survives is the detail that matters: the last of the conflicting classes. That fact is the foundation of the override pattern you’re about to learn, because when the consumer’s class comes last, the consumer wins.
tailwind-merge reads variants, not just raw strings, which is what lets you trust it. hover:bg-primary and hover:bg-accent conflict, but bg-primary and hover:bg-accent don’t, since they apply in different states; responsive prefixes work the same way. It also understands shorthand versus longhand: p-4 sets all four sides and px-8 sets only the horizontal axis, so it keeps both and lets px-8 win on that axis alone:
twMerge('p-4 px-8'); // -> 'p-4 px-8'Scrub through the sequence below to watch both jobs run on a real input: the raw arguments a component passes, through clsx, then through tailwind-merge.
clsx flattens everything into one string and drops the falsy opacity-50, but still carries both px-4 and px-8.
The last one wins: px-4 is deleted, the consumer’s px-8 stands, and the result is inline-flex rounded-md bg-primary py-2 px-8.
One caveat: tailwind-merge’s conflict knowledge is tied to a specific Tailwind version, so the installed version must match your Tailwind v4, and a mismatch fails quietly. The scaffold pins compatible versions, so just don’t bump one without the other.
Writing cn(): clsx then tailwind-merge
Section titled “Writing cn(): clsx then tailwind-merge”clsx decides which classes are present; tailwind-merge decides which conflicting one survives. Run them in that order and you get a function that flattens conditionals, then resolves conflicts. That is cn(), 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 inside out and it is the pipeline you just watched. clsx(inputs) runs first, flattening conditionals and dropping falsy values; its flat string feeds twMerge second, which resolves conflicts last-wins.
The signature ...inputs: ClassValue[] accepts any number of arguments in every shape clsx accepts, since ClassValue is clsx’s own input type: strings, conditionals, objects, and arrays in one call.
You don’t write this file. cn() ships at lib/utils.ts in the project scaffold, the convention every shadcn project follows, so every component in this course imports it rather than redefining it. The @/lib/utils alias gets you there from anywhere:
import { cn } from '@/lib/utils';cn() runs during render at a fraction of a millisecond per call, so at the scale of a web UI there’s no need to wrap it in useMemo.
The override pattern: className last
Section titled “The override pattern: className last”You’ll reach for this in nearly every reusable component. Here’s the <Button> from the start of the lesson, done right. 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} /> );};The base classes come first, the conditional (opacity-50 while submitting) in the middle, and the consumer’s className last. That order is the rule: inside cn(), the consumer’s className is always the last argument. Since tailwind-merge keeps the last conflicting class, last position is what lets a consumer’s px-8 delete the component’s px-4. Move className any earlier and the component’s defaults land last instead, so overrides silently stop working.
That closes the bug from the start of the lesson: <Button className="px-8"> now wins on every build, not by luck of emit order. Here’s the corrected version next to the naive one, then the argument order step by step.
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} /> );};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 last, so the consumer’s px-8 deletes px-4 and wins on every build.
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. Last position is what lets it 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 so the consumer’s onClick, aria-*, type, and the rest pass straight through to the real <button>.
Two boundaries keep you from over-applying the pattern.
Reach for cn() only when there’s something to merge: a conditional class or a consumer className. A static string like className="flex items-center gap-2" is fine plain; wrapping it adds nothing.
cn() belongs on the render path. It builds 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). This extends the debugging habit from earlier in this chapter: when a class you wrote isn’t in the DOM, check whether cn() merged it out because a later conflicting class deleted it.
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”clsx (and so cn()) accepts four conditional forms. All flatten the same way, so the choice between them is purely about which reads cleanest at the call site.
cn('rounded-md px-3 py-2', isActive && 'bg-accent');The default: one class toggled by one boolean. The left side must be a real boolean (more on that below).
cn('rounded-md px-3 py-2', { 'bg-accent': isActive, 'opacity-50': isPending });Several classes, each gated on its own boolean. Keeps the conditions aligned in one scannable block.
cn('transition-transform', isOpen ? 'rotate-180' : 'rotate-0');A two-sided choice: this class or that one, not present or absent.
cn(['rounded-md px-3 py-2', isActive && 'bg-accent']);Grouping classes assembled from separate pieces. Rare; mostly for building a list up programmatically.
One rule carries forward from earlier in this chapter: with the && form, the left side must be a real boolean. isActive && 'bg-accent' is fine because isActive is either true or false. But a falsy number or string leaks into the output: count && 'badge' returns 0 when count is 0, and now 0 is sitting in your class string. Coerce non-booleans first: Boolean(count) && 'badge', or value != null && '...' when you only mean to guard against null and undefined.
What tailwind-merge can’t see
Section titled “What tailwind-merge can’t see”cn() is reliable, but its conflict resolution covers only 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.
Everything else is invisible to it: hand-written CSS classes (card-legacy), classes from a third-party library (swiper-slide), and arbitrary-property forms that map to no known utility. tailwind-merge has no model for what CSS these set, so when two of them target the same property both survive and the cascade decides. The fix is the discipline you’ve already been following: stay on the utility surface, and treat bespoke classes as a deliberate, named boundary.
One escape hatch, named only so you recognize it: if you ship custom utilities (the @utility directive from configuring Tailwind in CSS) and need tailwind-merge to resolve their conflicts, extendTailwindMerge teaches it new conflict groups. You’ll rarely reach for it.
For each pair below, 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-activeSo the composition story for a single component is two facts: cn() is clsx then tailwind-merge (flatten the conditionals, then resolve the conflicts), and the consumer’s className goes last so their overrides win.
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.