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

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

Episode ini memperkenalkan class-variance-authority (CVA) untuk membangun komponen berbasis variants, pola utility wrapper memakai clsx dan tailwind-merge, serta cara menguji output class dalam unit test dengan snapshot dan DOM-based assertions.

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

Pendahuluan

Komponen Tailwind yang sehat butuh API yang rapi: bagaimana konsumen menambahkan class, bagaimana variants digabung, dan bagaimana konflik antar class diselesaikan. Episode 15 memperkenalkan tiga library yang menjadi standar industri: class-variance-authority (CVA), clsx, dan tailwind-merge.

Tanpa alat bantu ini, komponen mudah berubah menjadi perangkap: variants bersarang dengan ternary, class yang bertabrakan, dan override yang tidak bisa diprediksi. Dengan CVA plus utility wrapper, API komponen menjadi deklaratif, deterministik, dan mudah diuji.

Pengenalan class-variance-authority

CVA mendefinisikan variants komponen secara deklaratif:

Install library pendukung
npm install clsx tailwind-merge class-variance-authority
JSDefinisi variants dengan 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", {...}) menerima base class lalu tabel variants. Fungsi button({ variant: "primary", size: "lg" }) menghasilkan string class yang lengkap — dan tipe ButtonVariants menjaga pemakaian variants tetap aman di sisi TypeScript.

Utility Wrappers: clsx dan tailwind-merge

Menggabungkan Class dengan clsx

clsx menggabungkan class secara kondisional dengan rapi:

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

clsx("btn", isActive && "btn-active") mem-filter nilai falsy otomatis — jauh lebih bersih daripada rangkaian template string dan ternary.

Menghindari Konflik dengan tailwind-merge

Masalahnya: dua utility yang bertabrakan seperti px-4 dan px-6 akan saling menimpa secara tidak terduga. tailwind-merge menyelesaikannya — class terakhir yang menang:

JSResolusi konflik dengan 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") mengenali kelompok utility yang saling bertentangan dan memilih yang terakhir. Kombinasi khas dalam komponen:

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))} />;
}

Alur twMerge(clsx(button({...}), className)) menggabungkan variants CVA, kondisi dinamis, dan class dari konsumen — dengan konflik terselesaikan secara konsisten.

Testing Class Output

API komponen yang berbasis string class sangat mudah diuji. Dua pendekatan umum:

Snapshot testing pada fungsi CVA:

JSUnit test snapshot
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 dengan framework testing seperti Testing Library:

JSAssertion di DOM
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") memverifikasi bahwa override konsumen benar-benar diterapkan — ini menangkap regresi resolusi konflik yang sulit dilihat secara visual.

Info

Snapshot mudah rapuh jika berubah sering. Pilih snapshot untuk struktur variants yang stabil, dan gunakan assertion eksplisit (toHaveClass) untuk perilaku override yang penting — kombinasi keduanya menjaga uji tetap informatif.

Penutup

Episode 15 membawa kalian ke component API patterns modern: CVA untuk variants deklaratif, clsx dan tailwind-merge untuk penggabungan dan resolusi konflik, serta pengujian output class yang menjaga API tetap terkunci.

Inti yang harus dibawa pulang:

  • CVA mendefinisikan variants dan default secara deklaratif.
  • clsx menggabungkan class kondisional dengan filter falsy.
  • twMerge menyelesaikan konflik antar utility — yang terakhir menang.
  • Wiring khas: twMerge(clsx(button({...}), className)).
  • Snapshot test mengunci struktur variants.
  • Assertion toHaveClass mengunci perilaku override.

Di episode 16 selanjutnya kita akan membahas advanced responsive techniques & container queries — layout adaptif dengan container queries, fluid typography memakai clamp(), serta komposisi layout yang digerakkan oleh state.

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