Skip to main content

Command Palette

Search for a command to run...

Solving Tailwind Class Conflicts using the cn() Utility in React

Updated
•7 min read•View as Markdown
C
CS undergrad building web applications with React, Next.js, and TypeScript. I write deep-dive technical guides on frontend architecture, component libraries, and clean UI engineering.

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:

  1. This would most likely cause class conflict issues which tailwind doesn't handle gracefully at all.

  2. 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 out undefined, null, and false values 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., keeping px-10 over px-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));
}