Conditional Classes
Almost every interactive component needs to change its appearance based on state: an active tab, a disabled button, a validation error, a dark theme. In React that usually means building a className string conditionally. Doing this with raw template literals works, but it gets messy fast — stray spaces, nested ternaries, and undefined leaking into the DOM. This page shows the clean patterns: when a template literal is fine, when to reach for clsx (or classnames), and how tailwind-merge resolves conflicting utility classes.
The problem with template literals
A className is just a string, so the obvious approach is string interpolation. For a single conditional class it reads fine:
function Tab({ label, isActive, onSelect }) {
return (
<button
className={`tab ${isActive ? "tab--active" : ""}`}
onClick={onSelect}
>
{label}
</button>
);
}
The trouble starts as conditions multiply. Each branch must remember to emit a trailing space, falsy branches leave empty strings, and the expression becomes hard to scan:
function Button({ variant, size, disabled, isLoading }) {
// Hard to read, easy to break
const className =
"btn " +
(variant === "primary" ? "btn--primary " : "") +
(size === "lg" ? "btn--lg " : "") +
(disabled ? "btn--disabled " : "") +
(isLoading ? "btn--loading" : "");
return <button className={className.trim()}>Save</button>;
}
Watch out for
undefinedandfalse. A template literal happily stringifies them, so`card ${error && "card--error"}`produces the literal classfalsein the DOM whenerroris falsy. Always guard with a ternary or use a dedicated helper.
Composing classes with clsx
clsx is a tiny (around 240 bytes) utility that builds a className string from a mix of strings, objects, and arrays, ignoring any falsy values. The popular classnames package has an identical API; clsx is faster and smaller, so it is the common default in modern projects.
Install it with your package manager:
npm install clsx
The object form is the workhorse: keys are class names, values are the conditions that decide whether to include them.
import clsx from "clsx";
function Button({ variant = "primary", size = "md", disabled, isLoading }) {
const className = clsx("btn", `btn--${variant}`, `btn--${size}`, {
"btn--disabled": disabled,
"btn--loading": isLoading,
});
return (
<button className={className} disabled={disabled || isLoading}>
{isLoading ? "Saving…" : "Save"}
</button>
);
}
Falsy entries simply drop out, and clsx collapses the rest into a clean, single-spaced string:
Output:
<button class="btn btn--primary btn--md btn--loading" disabled>Saving…</button>
You can freely mix the argument styles — strings, arrays, and objects all work together, which is handy when merging a base set with conditional extras or a passed-in className prop:
import clsx from "clsx";
function Alert({ tone = "info", className, children }) {
return (
<div
className={clsx(
"alert",
["alert--bordered", "alert--rounded"],
{ "alert--danger": tone === "danger" },
className // consumer overrides come last
)}
role="alert"
>
{children}
</div>
);
}
clsx vs. classnames
| Feature | clsx | classnames |
|---|---|---|
| API (strings, arrays, objects) | Identical | Identical |
| Bundle size (minified) | ~240 B | ~730 B |
| TypeScript types | Built in | Built in |
| Default export | clsx | classNames |
Because the call signatures match, you can swap one for the other with a single import change.
Merging Tailwind classes
If you use Tailwind CSS, clsx alone has a blind spot: it does not resolve conflicting utilities. Passing both "px-4" and "px-8" yields "px-4 px-8", and the winner depends on CSS source order rather than your last-write intent. tailwind-merge fixes this by parsing utility classes and keeping only the last one in each conflicting group.
The idiomatic pattern is to combine the two into a small cn helper — the same one shadcn/ui ships:
// utils/cn.js
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
Now conditional logic and conflict resolution happen in one call. A base style can be overridden cleanly by props:
import { cn } from "../utils/cn";
function Card({ highlighted, className, children }) {
return (
<div
className={cn(
"rounded-lg border p-4 shadow-sm",
highlighted && "border-blue-500 bg-blue-50",
className // e.g. "p-8" reliably wins over "p-4"
)}
>
{children}
</div>
);
}
Output:
<!-- <Card className="p-8" /> renders: -->
<div class="rounded-lg border shadow-sm p-8">…</div>
Only use
tailwind-mergewith Tailwind. For plain CSS, CSS Modules, or BEM-style names it adds bundle weight and parsing cost with no benefit — reach forclsxby itself.
Best Practices
- Use a plain template literal only when there is a single conditional class; reach for
clsxthe moment you have two or more. - Prefer the object syntax (
{ "is-active": isActive }) for boolean toggles — it reads like a checklist of states. - Accept and forward a
classNameprop on reusable components, placing it last so consumers can override defaults. - Never interpolate raw booleans into a template literal; guard with a ternary or let
clsxstrip falsy values for you. - In Tailwind projects, wrap
clsxin acnhelper backed bytailwind-mergeso the last conflicting utility always wins. - Keep class logic in the JSX or a small derived variable — avoid building strings deep in event handlers where they are hard to find.