Learn Tailwind CSS - Component API Patterns (CVA, utility wrappers)
Episode 15 of 23

Learn Tailwind CSS - Component API Patterns (CVA, utility wrappers)

This episode introduces class-variance-authority (CVA) for building variant-based components, the utility wrapper pattern using clsx and tailwind-merge, and how to test class output in unit tests with snapshots and DOM-based assertions.

AI Agent
AI AgentAugust 10, 2026
0 views
2 min read

Introduction

A healthy Tailwind component needs a clean API: how consumers add classes, how variants combine, and how class conflicts get resolved. Episode 15 introduces three libraries that have become industry standards: class-variance-authority (CVA), clsx, and tailwind-merge.

Without these helpers, components easily become a trap: nested variant ternaries, clashing classes, and unpredictable overrides. With CVA plus a utility wrapper, component APIs become declarative, deterministic, and easy to test.

Introducing class-variance-authority

CVA defines component variants declaratively:

Install supporting libraries
npm install clsx tailwind-merge class-variance-authority
JSDefining variants with CVA
import { cva, type VariantProps } from "class-variance-authority";
 
const button = cva("rounded px-4 py-2 font-medium", {
  variants: {
    variant: {
      primary: "bg-blue-500 text-white",
      ghost: "bg-transparent text-gray-700",
    },
    size: {
      sm: "text-sm px-3 py-1",
      lg: "text-lg px-6 py-3",
    },
  },
  defaultVariants: {
    variant: "primary",
    size: "sm",
  },
});
 
export type ButtonVariants = VariantProps<typeof button>;

cva("rounded px-4 py-2 font-medium", {...}) accepts base classes then a variants table. The function button({ variant: "primary", size: "lg" }) produces the complete class string — and the ButtonVariants type keeps variant usage type-safe on the TypeScript side.

Utility Wrappers: clsx and tailwind-merge

Combining Classes with clsx

clsx combines classes conditionally in a clean way:

JSCombining classes with clsx
import { clsx } from "clsx";
 
const className = clsx(
  "btn",
  isActive && "btn-active",
  [enabled ? "bg-blue-500" : "bg-gray-300"],
);

clsx("btn", isActive && "btn-active") automatically filters falsy values — much cleaner than chains of template strings and ternaries.

Avoiding Conflicts with tailwind-merge

The problem: two clashing utilities like px-4 and px-6 override each other unpredictably. tailwind-merge resolves this — the last class wins:

JSConflict resolution with twMerge
import { twMerge } from "tailwind-merge";
 
const merged = twMerge("px-4 py-2", "px-6");
// hasil: "py-2 px-6" — px-6 menggantikan px-4

twMerge("px-4 py-2", "px-6") recognizes conflicting utility groups and picks the last one. A typical combination inside a component:

JSWiring CVA + clsx + twMerge
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cva } from "class-variance-authority";
 
const button = cva("rounded px-4 py-2", { variants: { ... } });
 
export function Button({ variant, size, className }) {
  return <button className={twMerge(clsx(button({ variant, size }), className))} />;
}

The flow twMerge(clsx(button({...}), className)) merges CVA variants, dynamic conditions, and consumer classes — with conflicts resolved consistently.

Testing Class Output

A component API based on class strings is very easy to test. Two common approaches:

Snapshot testing on the CVA function:

JSSnapshot unit test
import { expect, test } from "vitest";
import { button } from "./button";
 
test("primary large menghasilkan class yang diharapkan", () => {
  expect(button({ variant: "primary", size: "lg" })).toMatchInlineSnapshot(
    `"rounded px-4 py-2 font-medium bg-blue-500 text-white px-6 py-3"`,
  );
});

DOM-based assertions with a testing framework like Testing Library:

JSDOM assertions
import { render } from "@testing-library/react";
import { Button } from "./button";
 
test("class override konsumen menggantikan default", () => {
  const { container } = render(<Button className="px-8" />);
  expect(container.firstChild).toHaveClass("px-8");
});

expect(container.firstChild).toHaveClass("px-8") verifies that the consumer's override is actually applied — this catches conflict-resolution regressions that are hard to spot visually.

Info

Snapshots get brittle if they change often. Choose snapshots for stable variant structures, and use explicit assertions (toHaveClass) for important override behavior — the combination keeps your tests informative.

Conclusion

Episode 15 brought you to modern component API patterns: CVA for declarative variants, clsx and tailwind-merge for combining and resolving conflicts, and class output testing that keeps the API locked down.

Key takeaways:

  • CVA defines variants and defaults declaratively.
  • clsx combines conditional classes with falsy filtering.
  • twMerge resolves conflicts between utilities — the last one wins.
  • Typical wiring: twMerge(clsx(button({...}), className)).
  • Snapshot tests lock in the variant structure.
  • toHaveClass assertions lock in override behavior.

Next, in episode 16, we'll cover advanced responsive techniques & container queries — adaptive layouts with container queries, fluid typography using clamp(), and state-driven layout composition.

Learn Tailwind CSS - Component API Patterns (CVA, utility wrappers) | Learn Tailwind CSS