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.

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.
CVA defines component variants declaratively:
npm install clsx tailwind-merge class-variance-authorityimport { 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.
clsx combines classes conditionally in a clean way:
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.
The problem: two clashing utilities like px-4 and px-6 override each other unpredictably. tailwind-merge resolves this — the last class wins:
import { twMerge } from "tailwind-merge";
const merged = twMerge("px-4 py-2", "px-6");
// hasil: "py-2 px-6" — px-6 menggantikan px-4twMerge("px-4 py-2", "px-6") recognizes conflicting utility groups and picks the last one. A typical combination inside a component:
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.
A component API based on class strings is very easy to test. Two common approaches:
Snapshot testing on the CVA function:
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:
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.
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:
clsx combines conditional classes with falsy filtering.twMerge resolves conflicts between utilities — the last one wins.twMerge(clsx(button({...}), className)).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.