How the cn function works: understanding clsx and tailwind-merge
What you learn in this chapter
In the previous chapter, you learned that "cva does not resolve conflicts." The cn function is what resolves those conflicts. In this chapter, we dig into what clsx and tailwind-merge, which make up cn, do internally, while looking at actual results.
What is the cn function?
The cn function is a utility that combines multiple CSS class names safely.
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
It is only three lines, but it combines two libraries. cn is short for className, a name widely used in the shadcn/ui community.
Why is cn needed?
cn solves two different problems at once.
Problem 1: Conditional branching of class names
// ❌ Combining strings by hand is tedious
let className = "px-4 py-2 rounded"
if (isActive) className += " bg-primary text-primary-foreground"
if (isDisabled) className += " opacity-50"
Problem 2: Tailwind class conflicts
// ❌ Conflicting classes remain as-is
const base = "p-4 bg-primary"
const custom = "p-2 bg-destructive"
`${base} ${custom}` // => "p-4 bg-primary p-2 bg-destructive" (unclear which wins)
cn solves both of these.
// ✅ Conditional branching and conflict resolution all at once
cn("p-4 bg-primary", "p-2 bg-destructive") // => "p-2 bg-destructive"
These two jobs are handled by clsx and tailwind-merge, respectively.
The role and internal behavior of clsx
clsx is a library that normalizes various forms of input into a single class string (the latest is the 2.x series, about 240 bytes and very lightweight).
The input it accepts
import { clsx } from "clsx"
clsx("foo", "bar") // => "foo bar" (strings)
clsx(["foo", "bar"]) // => "foo bar" (an array)
clsx({ "bg-primary": true, "opacity-50": false }) // => "bg-primary" (object: only keys whose value is truthy)
clsx("base", { active: true }, ["extra"]) // => "base active extra" (mixed and nested are OK)
Falsy values are excluded automatically
An important property of clsx is that it drops falsy values (false / null / undefined / 0 / "" / NaN). When you actually run it, this is what happens.
clsx(true, false, "", null, undefined, 0, NaN, "keep")
// => "keep"
// Only the truthy string "keep" remains; all falsy values disappear
Thanks to this property, you can pass a conditional expression like isActive && "bg-primary" directly. If the condition is false, the result becomes false, and clsx drops it.
clsx("base", isActive && "bg-primary", isDisabled && "opacity-50")
// if isActive=true, isDisabled=false => "base bg-primary"
clsx alone does not resolve conflicts
However, clsx only joins things together. It does not resolve Tailwind conflicts.
clsx("p-4", "p-2") // => "p-4 p-2" (both remain)
The role and internal behavior of tailwind-merge
tailwind-merge (the latest is the 3.x series) is a library that resolves conflicts among Tailwind classes.
Basics: the same kind of class is "last wins"
import { twMerge } from "tailwind-merge"
twMerge("p-4", "p-2") // => "p-2" (the later one wins)
twMerge("bg-primary", "bg-destructive") // => "bg-destructive"
twMerge("p-4", "m-4") // => "p-4 m-4" (different kinds, so both remain)
The mechanism: conflict groups
tailwind-merge knows which "group" each Tailwind class belongs to. For example, padding has the groups p / px / py / pt / pr / pb / pl, and it understands the containment relationships among them.
From this, a seemingly strange asymmetric behavior arises. Here are the actual results.
twMerge("p-4", "px-6") // => "p-4 px-6" (both remain)
twMerge("px-6", "p-4") // => "p-4" (only p-4 remains)
twMerge("p-4", "px-6") → "p-4 px-6"
Even though px-6 (left/right) comes after p-4 (top/right/bottom/left),
px-6 covers only "left/right," so it cannot erase p-4's "top/bottom" → both remain
twMerge("px-6", "p-4") → "p-4"
When p-4 (top/right/bottom/left) comes after px-6 (left/right),
p-4 fully contains px-6 → it erases px-6 and only p-4 remains
In other words, it decides based on "whether the class that comes later can fully override the earlier class." The point is that it is not a simple "last wins."
It understands modifiers too
Modifiers like hover:, md:, and dark: are treated as separate groups.
twMerge("hover:bg-primary", "hover:bg-destructive") // => "hover:bg-destructive"
twMerge("bg-primary", "hover:bg-destructive") // => "bg-primary hover:bg-destructive" (different things, so both remain)
Why the order is clsx then twMerge
cn calls them in the order twMerge(clsx(inputs)). There is a reason for this order.
- clsx (first): normalizes conditional expressions, objects, arrays, and falsy values into "a single, plain class string" that
twMergecan handle - twMerge (second): resolves the conflicts within that string
Let's check what happens if you skip clsx and use only twMerge. twMerge does not error even if you pass a falsy argument; it ignores it.
twMerge("base", false && "active") // => "base" (false is ignored; it does not error)
It does not error, but twMerge cannot expand object notation ({ active: true }). To write conditions expressively, you need clsx. That is why the order is "build with clsx, then tidy up with twMerge."
You will sometimes see it explained as "twMerge cannot take booleans," but that is not accurate. Falsy values do not error when passed; they are simply ignored. The real reason to use clsx is to be able to handle conditional specification with object notation.
The cn flow of execution
cn("p-4", { "bg-primary": true }, "p-2 m-4")
│
│ Step 1: clsx (normalize and combine)
│ input: "p-4", { "bg-primary": true }, "p-2 m-4"
│ output: "p-4 bg-primary p-2 m-4"
↓
│ Step 2: twMerge (resolve conflicts)
│ input: "p-4 bg-primary p-2 m-4"
│ output: "bg-primary p-2 m-4" ← p-4 loses to p-2
↓
final result: "bg-primary p-2 m-4"
Performance: the LRU cache
tailwind-merge's conflict resolution has a computational cost. So it stores the results it computes once in an LRU cache (up to 500 entries by default), and returns them instantly on the second and subsequent times the same input arrives. Even in cases like list rendering where you draw the same button many times, the practical speed is rarely a problem.
A pitfall: unrecognized custom classes
tailwind-merge can judge conflicts only for classes that Tailwind knows about. For classes you define yourself, conflict detection may not work correctly.
twMerge("text-sm my-custom-text text-lg")
// my-custom-text is a class Tailwind does not know,
// so it may not be handled as expected
If you want your custom classes to be merged correctly, register them as a group with extendTailwindMerge.
import { extendTailwindMerge } from "tailwind-merge"
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": ["my-custom-text"], // register it as a font-size class
},
},
})
It is important not to assume that "if you pass it through cn, any class will always be merged correctly." What tailwind-merge can resolve is, after all, Tailwind's standard classes (and registered custom classes).
Practical examples
Example 1: Conditional styles
function Button({ isActive, isDisabled }: { isActive?: boolean; isDisabled?: boolean }) {
return (
<button
className={cn(
"px-4 py-2 rounded-md",
isActive && "bg-primary text-primary-foreground",
isDisabled && "opacity-50 cursor-not-allowed"
)}
>
Click
</button>
)
}
Example 2: Overriding default styles
function Card({ className }: { className?: string }) {
return <div className={cn("rounded-lg bg-card p-4 shadow", className)} />
}
// On the usage side
<Card className="p-8 bg-muted" />
// => "rounded-lg shadow p-8 bg-muted"
// p-4 was overridden by p-8, and bg-card by bg-muted
Example 3: Switching between multiple states with object notation
clsx's object notation ({ "class": condition }) is well suited to choosing among multiple mutually exclusive states. The Button's variants are handled by cva, but for such plain state expressions, cn alone shines.
function StatusBadge({ status }: { status: "success" | "error" | "idle" }) {
return (
<span
className={cn("rounded-full px-2 py-1 text-xs font-medium", {
"bg-green-100 text-green-800": status === "success",
"bg-red-100 text-red-800": status === "error",
"bg-gray-100 text-gray-800": status === "idle",
})}
>
{status}
</span>
)
}
Only the key that is true remains, so just one color scheme is applied depending on status.
Example 4: Dynamically choosing classes from a value
By mixing in ternary operators and &&, you can build classes from prop values.
function Grid({ columns, isCompact }: { columns: 2 | 3; isCompact: boolean }) {
return (
<div
className={cn(
"grid gap-4",
columns === 2 && "grid-cols-2",
columns === 3 && "grid-cols-3",
isCompact ? "p-2" : "p-6"
)}
>
{/* ... */}
</div>
)
}
clsx drops the && (false) cases that do not match the condition, so only the applicable classes remain.
cn in the Button
The Button from the chapter on the finished form is exactly the pattern we saw in Example 2: "combine the user's className onto the default styles."
function Button({ className, variant, size, asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
// ↑ cn resolves conflicts between the classes cva assembled + the user's className
{...props}
/>
)
}
The asChild ? Slot.Root : "button" in this code is the mechanism for swapping the element to render (asChild), which we cover in detail in the Slot chapter. Here, focus on the cn(buttonVariants(...)) part — the point that cn resolves conflicts between the classes cva assembled and the user's className.
When a user overrides via className, it is reflected safely thanks to cn.
<Button className="px-8">Submit</Button>
// the user's "px-8" is combined onto buttonVariants's "h-9 px-4 py-2 ..."
// cn overrides px-4 with px-8 → px-8 takes effect as expected
Versions and Tailwind compatibility
clsx: does not depend on the Tailwind version (framework-independent)tailwind-merge: match it to the major version of Tailwind you use
| Tailwind CSS | tailwind-merge |
|---|---|
| v3 | v2.x |
| v4 | v3.x (this book uses this) |
The fine-grained compatibility is updated per version, so check the exact combination in the official compatibility docs.
Summary of this chapter
- cn =
clsx+tailwind-merge. clsx assembles, and tailwind-merge resolves conflicts - clsx drops falsy values (
false/null/undefined/0/""/NaN) and normalizes conditional expressions and objects - tailwind-merge judges conflicts using conflict groups (the asymmetric behavior of
pandpxis an example) - The order is
clsx → twMergeso that conditional specifications are normalized into a string first - Custom classes are not resolved automatically (register them with
extendTailwindMerge)
In the next chapter, we learn how to pass a ref to a child component, centered on the React 19 way.