React + Tailwind: the cn Utility, clsx and class-variance-authority
AI generated
</>
{ }
React · Tailwind CSS · clsx · CVA · Design System
React + Tailwind: the cn Utility,
clsx and class-variance-authority

String concatenation for Tailwind classes is error-prone, hard to read and does not scale to a real design system. The combination of clsx, tailwind-merge and class-variance-authority (CVA) solves this elegantly: type-safe variants, conflict-safe class merging and a single cn utility for the entire codebase.

14 min read clsx · tailwind-merge · CVA · cn · Radix UI · shadcn/ui React 18 · Tailwind CSS v4 · TypeScript

1. The problem with Tailwind classes in React components

Tailwind CSS solves the CSS scaling problem through utility-first classes. React components solve the UI composition problem through encapsulation and props. Bringing both concepts together is simple at first, until a component needs several variants, sizes and states. A button in a primary and secondary variant, in small, medium and large, disabled, loading, with an icon: that quickly adds up to dozens of combinations. With string concatenation like {`btn ${variant === 'primary' ? 'bg-blue-600' : 'bg-gray-200'} ${size === 'lg' ? 'px-6 py-3' : 'px-4 py-2'}`}, the code quickly becomes unreadable and error-prone.

The second problem is Tailwind class conflicts. If a parent component wants to pass a padding override, className="p-0", but the component already has p-4 internally, the final DOM element ends up with both classes: p-4 p-0. Tailwind produces unpredictable behavior in this case because the order in the CSS bundle decides, not the order in the HTML attribute. This is a fundamental problem that plain Tailwind and string concatenation cannot solve on their own, and tailwind-merge solves it.

2. clsx: writing conditional classes readably

clsx is a tiny library (under 300 bytes) that merges different inputs into a single class string. It accepts strings, objects and arrays, and automatically filters out falsy values (undefined, null, false). This lets you write conditional classes as objects: clsx({ 'bg-blue-600': isPrimary, 'bg-gray-200': !isPrimary }). That is more readable, more maintainable and less error-prone than ternary expressions in template literals. Arrays make it possible to merge multiple class sources: base classes, variant classes and external prop classes.

One important use case for clsx is merging internal classes with an optional className prop. Every reusable component should accept a className prop so that consumers of the component can make fine-grained adjustments without overriding the whole component. With clsx(baseClasses, className), both sources merge into a single string, but still without conflict resolution, which does not yet fully solve the problem from section 1.

3. tailwind-merge: resolving class conflicts

tailwind-merge understands the semantics of Tailwind classes and resolves conflicts by prioritizing later classes over earlier ones. twMerge('p-4 p-0') results in 'p-0', the later class wins, exactly how CSS cascading works. This lets component consumers override Tailwind classes via the className prop, a fundamental requirement for a flexible design system. Without tailwind-merge, every class override is unreliable because the order in the CSS bundle decides the outcome, not the order of the props.

tailwind-merge supports all standard Tailwind classes including responsive prefixes (sm:p-4), dark mode (dark:bg-slate-800), state modifiers (hover:bg-blue-700) and custom values (p-[13px]). For projects with custom Tailwind configurations, you can use extendTailwindMerge to define your own class groups that are merged correctly as well. The performance overhead of tailwind-merge is negligible; the library uses internal caching and is optimized for runtime efficiency.


// lib/utils.ts, the cn utility used throughout the entire codebase
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

/**
 * Combines clsx (conditional classes) with tailwind-merge (conflict resolution).
 * Use this instead of raw clsx or string concatenation for all Tailwind classes.
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Usage examples:
// cn('p-4 text-sm', 'p-0')           returns 'text-sm p-0'   (conflict resolved)
// cn('bg-blue-600', { 'opacity-50': isDisabled })  conditional class
// cn(baseStyles, variantStyles, className)          merge from all sources
// cn(['px-4 py-2', 'text-sm'], 'font-bold')        array + string

// Component with className override support
interface BadgeProps {
  children: React.ReactNode;
  variant?: 'default' | 'success' | 'warning';
  className?: string; // allow consumers to override any class
}

const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => (
  <span
    className={cn(
      // Base styles always applied
      'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
      // Variant styles, only one applies
      variant === 'default' && 'bg-slate-100 text-slate-700',
      variant === 'success' && 'bg-green-100 text-green-700',
      variant === 'warning' && 'bg-yellow-100 text-yellow-700',
      // Consumer override, twMerge resolves any conflicts
      className
    )}
  >
    {children}
  </span>
);

4. The cn utility: clsx + tailwind-merge combined

The cn function is the central utility for every React-Tailwind project. It combines clsx for conditional class composition with tailwind-merge for conflict resolution in a single function. This function belongs in a central lib/utils.ts file and is imported into every component that composes Tailwind classes. This is the de-facto standard pattern in the React-Tailwind community, used by shadcn/ui, Radix UI Themes and hundreds of open-source component libraries.

One important stylistic benefit of the cn utility: it lets you split classes across multiple lines and thereby clearly separate different responsibilities, layout classes, typography classes, color classes and state classes stand apart and are understandable at a glance. In complex components with 20+ Tailwind classes, this is not a luxury but a necessity for maintainability. Prettier plugins such as prettier-plugin-tailwindcss sort the classes inside every cn() call alphabetically according to Tailwind convention.

5. class-variance-authority: variant components

class-variance-authority (CVA) is the solution for components with several variants, sizes and states. Instead of maintaining a growing chain of if-else conditions or a huge lookup object, you define the variants declaratively. CVA generates a function that accepts props and returns the complete class string, fully type-inferred through TypeScript. When you add a new variant, you only need to define it in one place, and TypeScript automatically flags every spot that does not explicitly handle it.

CVA separates variant logic from component logic. This makes it possible to export variant configurations and verify them in tests without rendering a React component. In complex design systems with dozens of components, this separation is essential for testability and documentability. Storybook addons can automatically detect CVA variants and generate them as controls, which considerably simplifies component documentation.


// components/Button.tsx, class-variance-authority with full TypeScript inference
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

// Define all variants in one place, TypeScript infers the types automatically
const buttonVariants = cva(
  // Base classes applied to all variants
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-sky-700 text-white hover:bg-sky-800 focus-visible:ring-sky-600',
        destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
        outline: 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-50',
        ghost: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900',
        link: 'text-sky-700 underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
);

// Merge CVA variants with HTML button props + optional className override
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  isLoading?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  className,
  variant,
  size,
  isLoading,
  children,
  ...props
}) => (
  <button
    className={cn(buttonVariants({ variant, size }), className)}
    disabled={isLoading || props.disabled}
    {...props}
  >
    {isLoading && <span className="animate-spin">⟳</span>}
    {children}
  </button>
);

export { buttonVariants };

6. Compound variants: combined states

Compound variants are a CVA feature for classes that are only active when several variants simultaneously have a particular value. The classic example: a button in the outline variant combined with the destructive color needs different classes than either variant alone. With regular variants you would have to model that through nested conditions, with compound variants you declare it directly: "if variant === 'outline' AND color === 'destructive', add these classes." This keeps the variant configuration flat and understandable, even with complex combinations of states.

Another use case for compound variants is state-dependent animation. A spinner element that only gets a certain size when size === 'sm' and isLoading === true can be defined as a compound variant without complicating the base classes. CVA guarantees that these combinations are fully type-safe, the TypeScript compiler immediately warns when a prop combination is passed that is not defined.

7. shadcn/ui as a CVA pattern in practice

shadcn/ui is the most popular React component library that uses CVA, clsx and tailwind-merge as its foundation. The crucial difference from other libraries: shadcn/ui copies components into your own project instead of installing them as an NPM package. This gives teams full control over every component, you can add variants, adjust classes and change behavior without forking a library. The components are readable CVA implementations that also serve as learning material for the pattern.

The shadcn/ui CLI tool generates components directly into the components/ui/ folder. Every component brings the required NPM dependencies with it, typically @radix-ui/* for accessibility primitives and class-variance-authority for variants. Radix UI provides the functional foundation (keyboard navigation, ARIA attributes, focus management), shadcn/ui adds styling via CVA. Anyone who understands this pattern can build their own design system components following the same principle.

8. Design tokens and Tailwind v4

Tailwind CSS v4 moves from tailwind.config.js to CSS-first configuration with CSS custom properties. Design tokens are defined directly in CSS: --color-primary: oklch(54% 0.2 250). This simplifies integration with design tools like Figma, which export tokens as CSS variables. Combined with CVA, that means variant classes use token-based colors (text-primary bg-primary/10), and a color update in the CSS token automatically updates every component that uses that token, without touching any CVA configuration.

The interplay of CVA and Tailwind v4 CSS variables enables multi-theming without JavaScript. Different CSS classes on the html element (class="theme-corporate", class="theme-minimal") switch to different CSS variable values, and CVA variants inherit that automatically. This is a considerable advantage over CSS-in-JS solutions: zero runtime overhead, full SSR compatibility and direct control via CSS, without React state or context for theming.


/* CSS-first design tokens, the Tailwind v4 approach */
@import "tailwindcss";

@theme {
  /* Brand colors as CSS custom properties */
  --color-brand-500: oklch(54% 0.2 250);
  --color-brand-600: oklch(47% 0.22 250);
  --color-brand-700: oklch(40% 0.24 250);

  /* Semantic tokens, reference brand colors */
  --color-primary: var(--color-brand-600);
  --color-primary-hover: var(--color-brand-700);

  /* Typography scale */
  --font-size-base: 1rem;
  --line-height-base: 1.5;
}

/* Theme override, swap tokens without touching components */
.theme-warm {
  --color-primary: oklch(54% 0.22 40);  /* warm orange */
  --color-primary-hover: oklch(47% 0.24 40);
}

9. Styling approaches compared

The choice of styling approach for React projects affects performance, development speed and long-term maintainability. Tailwind with CVA is not the best choice for every use case, but it is the most pragmatic one for most React projects.

Approach Bundle size Type safety Design system fit
Tailwind + CVA + cn Very small (PurgeCSS) Full, via VariantProps Very good
CSS Modules Small Limited Good (with type generator)
styled-components Large (runtime CSS-in-JS) Full Good
Vanilla Extract Small (compile time) Full Good (complex)
Inline styles Minimal Partial Poor (no hover/focus)

Tailwind with CVA dominates the combination of small bundle size, full type safety and good design system fit. The decisive advantage over CSS-in-JS solutions: zero runtime overhead, because all styles are generated at build time. Compared to CSS Modules, CVA is more type-safe and allows variant composition without manual TypeScript definitions. Vanilla Extract is a valid alternative for teams who prefer more CSS semantics and fewer utility classes.

Mironsoft

React design systems with Tailwind, CVA and TypeScript

Building a design system with React and Tailwind?

We plan and implement your React component system with CVA, the cn utility and Tailwind v4, scalable, type-safe and maintainable, without copy-paste styling.

Component library

CVA-based variant components with full TypeScript typing

Design tokens

Tailwind v4 CSS-first tokens with theming support and Figma integration

Storybook documentation

CVA variants as Storybook controls, every state visible at a glance

10. Summary

The interplay of clsx, tailwind-merge and class-variance-authority solves the three biggest problems when combining React and Tailwind CSS: writing conditional classes readably, resolving class conflicts on override, and defining component variants in a type-safe and maintainable way. The cn utility is the central abstraction that combines clsx and tailwind-merge, a single function for every class operation across the entire codebase. CVA with VariantProps makes components fully type-safe: TypeScript knows every allowed variant and warns on invalid combinations.

shadcn/ui has popularized these patterns in the community and shows how they work together in a production-ready component library. Anyone who understands the three layers, accessibility primitives (Radix UI), variant styling (CVA + cn) and design tokens (Tailwind v4 CSS custom properties), can build their own component libraries following the same pattern: maintainable, type-safe and without runtime overhead from CSS-in-JS.

React + Tailwind + CVA, the essentials at a glance

cn utility first

Create lib/utils.ts with cn = twMerge(clsx(...)), use this one function for every Tailwind class operation, never concatenate directly.

CVA for variants

cva() defines all variants declaratively. VariantProps<typeof xVariants> gives you TypeScript types for free, no manual interface definitions.

className prop always

Every reusable component accepts a className prop and merges it with cn(), consumers can pass any Tailwind override.

Tokens in CSS vars

Tailwind v4 CSS custom properties for design tokens, theming without a JavaScript runtime, full SSR compatibility.

11. FAQ: React + Tailwind with cn, clsx and CVA

1Difference between clsx and classnames?
clsx is the smaller, faster successor with no dependencies, under 300 bytes. Same API, always choose clsx for new projects.
2Why isn't clsx alone enough?
clsx does not resolve conflicts. "p-4 p-0" stays "p-4 p-0", which one wins depends on the CSS bundle order. tailwind-merge turns it into "p-0".
3What is class-variance-authority?
Declarative, type-safe variant definitions for components. cva() creates a function, TypeScript infers all variant types automatically.
4What are compound variants?
Classes that only become active when several variants simultaneously have particular values, declarative, type-safe, without nested conditions.
5Why shadcn/ui, CVA and tailwind-merge?
The most pragmatic pattern: CVA for type-safe variants, tailwind-merge for overridable classes. shadcn/ui copies components into the project, full control stays with the team.
6Tailwind v4 with CSS custom properties?
@theme { --color-primary: oklch(...) } defines tokens directly in CSS. Theming via CSS classes on html, no JavaScript, no runtime overhead.
7className prop in every component?
Yes, for every reusable component. Merged correctly with cn() and conflicts resolved, consumers can pass any Tailwind override.
8Sort Tailwind classes automatically?
Register prettier-plugin-tailwindcss in .prettierrc, it sorts automatically, including inside cn() and cva() calls.
9Test CVA variants?
Call the cva() function directly in unit tests without React, buttonVariants({ variant: 'outline' }) returns the class string, which you check with toEqual.
10Is CVA possible without Tailwind?
Yes. CVA is framework-agnostic and only generates strings. Usable with CSS Modules, UnoCSS or BEM classes. tailwind-merge is Tailwind-specific.