Skip to content
React rc styling 4 min read

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 undefined and false. A template literal happily stringifies them, so `card ${error && "card--error"}` produces the literal class false in the DOM when error is 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

Featureclsxclassnames
API (strings, arrays, objects)IdenticalIdentical
Bundle size (minified)~240 B~730 B
TypeScript typesBuilt inBuilt in
Default exportclsxclassNames

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-merge with Tailwind. For plain CSS, CSS Modules, or BEM-style names it adds bundle weight and parsing cost with no benefit — reach for clsx by itself.

Best Practices

  • Use a plain template literal only when there is a single conditional class; reach for clsx the 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 className prop 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 clsx strip falsy values for you.
  • In Tailwind projects, wrap clsx in a cn helper backed by tailwind-merge so 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.
Last updated June 14, 2026
Was this helpful?