← Home
3 min read

CLSX & TW Merge Underrated Duo

best-practices

The Tailwind duo you probably don’t think you need

For a long time I avoided adding CLSX and TW Merge to my projects. Extra dependencies. More things to explain to the next person. I was keeping things lean, or so I told myself.

What I was actually doing was writing string concatenation by hand and pretending it was fine.

If you use Tailwind CSS and you are not using these two utilities, you are managing problems that don’t need to exist.


The problem you’re probably living with

You have a component. It has a base style. It has variants. And sometimes the consumer of that component wants to override something. So you end up with something like this:

const Button = ({
  variant = "primary",
  size = "md",
  isDisabled,
  className,
  children,
}) => {
  let classes = "font-medium rounded-md transition-all";

  if (variant === "primary")
    classes += " bg-blue-600 text-white hover:bg-blue-700";
  else if (variant === "secondary")
    classes += " bg-gray-200 text-gray-900 hover:bg-gray-300";

  if (size === "sm") classes += " px-3 py-1.5 text-sm";
  else if (size === "md") classes += " px-4 py-2 text-base";

  if (isDisabled) classes += " opacity-50 cursor-not-allowed";

  return (
    <button className={`${classes} ${className || ""}`}>{children}</button>
  );
};

This works. Until someone passes className="bg-red-500 p-8" as an override.

Now your output is bg-blue-600 bg-red-500 px-4 py-2 p-8. Both background classes are in there. Both padding classes are in there. CSS will pick one based on stylesheet order, not the order you wrote them. It’s unpredictable, and it gets worse the more variants you add.

That’s the pain. You just might not have named it yet.


CLSX handles the conditionals

CLSX is a tiny utility that builds class strings conditionally. That’s it. No magic. But the difference in readability is real.

Instead of stringing conditionals together:

let classes = "font-medium rounded-md";
if (variant === "primary") classes += " bg-blue-600 text-white";
if (isDisabled) classes += " opacity-50 cursor-not-allowed";

You write it as a single expression:

import { clsx } from "clsx";

const classes = clsx("font-medium rounded-md", {
  "bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
  "bg-gray-200 text-gray-900 hover:bg-gray-300": variant === "secondary",
  "px-3 py-1.5 text-sm": size === "sm",
  "px-4 py-2 text-base": size === "md",
  "opacity-50 cursor-not-allowed": isDisabled,
});

The conditional logic is explicit. No string concatenation. No spacing bugs. And you can see at a glance what applies when.


TW Merge handles the conflicts

TW Merge solves a different problem. It understands Tailwind’s class hierarchy and resolves conflicts by keeping the last one.

When you do this:

import { twMerge } from "tailwind-merge";

twMerge("bg-white text-gray-900 p-4", "bg-blue-500 text-white p-8");

The output is bg-blue-500 text-white p-8. Not all six classes jammed together. It knows bg-blue-500 and bg-white conflict, so it keeps the later one. Same with p-4 and p-8.

This is what makes external overrides actually work. When a consumer passes a className prop to your component, TW Merge ensures their override wins cleanly instead of sitting in the string fighting the base styles.


Put them together and you get this

The pattern that shadcn/ui popularised is a single utility function that combines both:

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

CLSX builds the conditional string. TW Merge cleans up the conflicts. One function, two problems solved.

Here is what the earlier Button looks like with cn:

const Button = ({
  variant = "primary",
  size = "md",
  isDisabled,
  className,
  children,
}) => {
  return (
    <button
      className={cn(
        "font-medium rounded-md transition-all",
        {
          "bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
          "bg-gray-200 text-gray-900 hover:bg-gray-300":
            variant === "secondary",
          "bg-transparent text-blue-600 border border-blue-600":
            variant === "tertiary",
        },
        {
          "px-3 py-1.5 text-sm": size === "sm",
          "px-4 py-2 text-base": size === "md",
          "px-6 py-3 text-lg": size === "lg",
        },
        { "opacity-50 cursor-not-allowed": isDisabled },
        className,
      )}
      disabled={isDisabled}
    >
      {children}
    </button>
  );
};

Pass className="bg-red-500 p-8" now and the result is clean: bg-red-500 p-8 wins, the conflicting base classes are gone, everything else stays. Predictable. Exactly what you intended.


Why I avoided it for so long

Honestly, I did not know what problem I was solving until I felt the pain directly. A project with enough variants and enough external overrides and suddenly you are debugging styles that look right on paper but render wrong in the browser. By the time I went looking for a solution I found shadcn’s source, saw the cn function, and realised this was a solved problem and I had just been doing it the hard way.

Drop it into your utils, use it everywhere you touch Tailwind classes. You will not miss the string concatenation.