Solving Tailwind Class Conflicts using the cn() Utility in React
This article explains what the cn() utility is, how to build it with tailwind-merge & clsx, and how to use it with Tailwind CSS in React applications.
Prerequisites: A basic understanding of React, JavaScript/TypeScript, Tailwind, and Class Variance Authority (CVA).
Table of Contents
The Custom Override Problem
In my previous article on Class Variance Authority (CVA), we explored replacing messy nested ternaries with structured, type-safe variant schemas.
We ended up with an ActionButton component that looks like this:
export function ActionButton({
intent = "primary",
size = "cut",
isDisabled = false,
className,
children,
onClick,
...props
}: ButtonProps) {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isDisabled) return;
onClick?.(e);
};
return (
<button
disabled={isDisabled || props.disabled}
className={buttonVariants({ intent, size, isDisabled })}
onClick={handleClick}
{...props}
>
{children}
</button>
);
}
The component is styled based on the intent and size props passed to it. Depending on the value, CVA constructs a class string for the component when it is rendered. There is a problem, though: This system allows no way for the user of the library to customize the button to their taste. The className prop is right there, but it's not employed.
Why Simple String Interpolation Fails
It might seem like a good idea to just add the className prop to the className attribute like so:
<button
disabled={isDisabled || props.disabled}
className={`${buttonVariants({ intent, size, isDisabled })}, ${className}`}
onClick={handleClick}
{...props}
>
{children}
</button>
However, there are two issues with this idea:
This would most likely cause class conflict issues which tailwind doesn't handle gracefully at all.
Passing conditional expressions or optional style props can create malformed class strings, since if these expressions evaluate to null or undefined, those are also passed into the class string.
For example, if the button is called and a value is passed to the className prop like so:
<ActionButton intent="primary" size="cut" className="bg-fuchsia-500, px-5" />
The class string will have both bg-blue-500 and bg-fuchsia-500. Which one of these renders? Probably not the one you think. Contrary to what you might expect, when there are two or more conflicting CSS rules, the browser determines which rule to follow based on the order on their order in the stylesheet, and not their order in the class string. Tailwind generates the stylesheet in a fixed, static order, which likely won't favour "bg-fuchsia-500".
Understanding clsx vs tailwind-merge
You can use tailwind-merge to handle class conflicts and clsx to handle the conditional classes. tailwind-merge ensures that the last-passed class takes precedence. This means that when a user appen custom classes to the className attribute, they take precedence over the default styling.
To use tailwind-merge, first install it:
# npm
npm install tailwind-merge
# pnpm
pnpm add tailwind-merge
# bun
bun add tailwind-merge
# yarn
yarn add tailwind-merge
# deno
deno add tailwind-merge
Then, import the twMerge function into your code:
import { twMerge } from 'tailwind-merge';
You can then wrap your buttonVariants call and the incoming className inside twMerge:
<button
disabled={isDisabled || props.disabled}
className={twMerge(
buttonVariants({ intent, size, isDisabled, isLoading }),
className,
)}
onClick={handleClick}
{...props}
>
{children}
</button>
What clsx Brings to the Table
While tailwind-merge handles resolving conflicting classes, clsx is used to add classes to the class string conditionally. clsx accepts strings, objects (where the key is the class to be added and the value is a boolean expression), or arrays of both. The boolean expression's class strings are ignored if they are false, null, or undefined. Otherwise, twMerge adds these classes to the className attribute.
To install clsx, use the same formula as tailwind-merge:
# npm
npm install clsx
# pnpm
pnpm add clsx
# bun
bun add clsx
# yarn
yarn add clsx
# deno
deno add clsx
After that, you can import clsx and call it in the className attribute:
import { clsx } from "clsx";
export function ActionButton({
intent = "primary",
size = "cut",
isDisabled = false,
className,
children,
onClick,
...props
}: ButtonProps) {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isDisabled) return;
onClick?.(e);
};
const isActive = true;
const hasError = false;
return (
<button
disabled={isDisabled || props.disabled}
className={clsx(
buttonVariants({ intent, size, isDisabled }),
{ "is-active": isActive, "bg-red-600": hasError },
[hasError && "border-red-500"],
className,
)}
onClick={handleClick}
{...props}
>
{children}
</button>
);
}
Now, clsx adds 'is-active' since isActive is truthy, but ignores 'bg-red-600' and 'border-red-500' because hasError is false. If the className prop has a value, it adds that too; otherwise, its value is undefined and it is therefore ignored.
Building the cn() Utility
These two utilities are both incredibly useful, and you might find that you require both in your project. The cn() utility is the industry solution to that problem. It's a custom utility made by combining both tailwind-merge and clsx. When you wrap clsx inside twMerge, clsx first filters out all false, null, and undefined values and flattens your objects or arrays into a clean string. Then, twMerge parses that clean string to resolve any overlapping Tailwind utilities.
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Integrating cn() into the ActionButton
This combination gives your component the flexibility to pass conditional class objects, arrays, or strings into className without breaking specificity rules:
<ActionButton
intent="primary"
className={cn("mt-4", {
"opacity-50 pointer-events-none": isPending,
"border-2 border-green-500": isSuccess,
})}
>
Submit
</ActionButton>
Summary & Quick Reference Cheat Sheet
Building custom UI components in React requires balancing internal design defaults with external flexibility. While Class Variance Authority (CVA) provides a clean, type-safe API for defining internal component variants, combining clsx and tailwind-merge into a unified cn() utility guarantees that external overrides work safely without CSS specificity issues.
Tool Breakdown
clsx: Solves the dirty string & conditional logic problem. It accepts strings, object maps, arrays, or boolean expressions, filtering outundefined,null, andfalsevalues so your HTML stays clean.tailwind-merge: Solves the CSS specificity & stylesheet order conflict. It parses Tailwind utilities and strips out conflicting earlier classes (e.g., keepingpx-10overpx-5), ensuring consumer overrides always take precedence.cn(): The ultimate wrapper (twMerge(clsx(inputs))) that gives you flexible conditional inputs and conflict-free Tailwind output in a single function call.
Quick Comparison
| Approach / Tool | Handles Conditionals? | Filters Falsy (null/undefined)? |
Resolves Tailwind Conflicts? | Primary Use Case |
|---|---|---|---|---|
Template Literals (`${a} ${b}`) |
❌ Messy | ❌ Renders "undefined" |
❌ No | Fixed string concatenation |
clsx |
✅ Excellent | ✅ Yes | ❌ No | Toggling conditional class strings |
tailwind-merge |
❌ Limited | ❌ Basic | ✅ Yes | Stripping overlapping Tailwind utilities |
cn() (clsx + twMerge) |
✅ Excellent | ✅ Yes | ✅ Yes | Production React component libraries & CVA |
The Production cn() Implementation
Save this in src/utils/cn.ts (or src/lib/utils.ts):
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combines conditional class names with clsx and resolves
* Tailwind CSS utility conflicts using tailwind-merge.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}