CVA: Quản Lý Variants Type-Safe Cho React Components
Bạn đã bao giờ nhìn vào một component Button với 10 if/else để gán class CSS chưa? Thêm variant mới là thêm bug. Đổi tên class là sửa 5 chỗ. TypeScript không biết variant nào hợp lệ. Đây là vấn đề phổ biến khi scale design system trong React.
Class Variance Authority (CVA) giải quyết bài toán này: định nghĩa variants một lần, tự động generate class đúng, và TypeScript biết chính xác prop nào hợp lệ. Không cần viết type riêng — type được derive tự động từ config.
#1. Vấn Đề Khi Không Có CVA
Cách quản lý variants thông thường nhanh chóng trở nên khó maintain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ Không có CVA — dễ sai, khó maintain
function Button({ variant, size, children }) {
const classes = [
"rounded font-medium transition-colors",
variant === "primary" ? "bg-blue-500 text-white hover:bg-blue-600" : "",
variant === "secondary" ? "bg-gray-200 text-black hover:bg-gray-300" : "",
variant === "danger" ? "bg-red-500 text-white hover:bg-red-600" : "",
variant === "ghost" ? "bg-transparent border hover:bg-gray-100" : "",
size === "sm" ? "text-sm px-2 py-1" : "",
size === "md" ? "text-base px-4 py-2" : "",
size === "lg" ? "text-lg px-6 py-3" : "",
].filter(Boolean).join(" ");
return <button className={classes}>{children}</button>;
}
// Vấn đề:
// - Không có type → TypeScript không báo khi dùng variant="invalid"
// - Thêm variant "outline" → phải sửa cả function và type riêng
// - Class có thể bị duplicate hoặc conflict#2. CVA Cơ Bản
CVA là một function nhận config và trả về function generate class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { cva } from "class-variance-authority";
const button = cva(
// Base classes — luôn áp dụng
"rounded font-medium transition-colors focus:outline-none focus:ring-2",
{
variants: {
variant: {
primary: "bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-300",
secondary: "bg-gray-200 text-black hover:bg-gray-300 focus:ring-gray-200",
danger: "bg-red-500 text-white hover:bg-red-600 focus:ring-red-300",
ghost: "bg-transparent border border-gray-300 hover:bg-gray-100",
},
size: {
sm: "text-sm px-2 py-1",
md: "text-base px-4 py-2",
lg: "text-lg px-6 py-3",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
// Dùng function để generate class
button({ variant: "danger", size: "lg" });
// → "rounded font-medium ... bg-red-500 text-white ... text-lg px-6 py-3"
button({ variant: "invalid" }); // ❌ compile error ngay
button(); // ✅ dùng defaultVariantsTích hợp vào React component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { cva } from "class-variance-authority";
const button = cva("rounded font-medium", {
variants: {
variant: { primary: "bg-blue-500 text-white", danger: "bg-red-500 text-white" },
size: { sm: "text-sm px-2", md: "text-base px-4", lg: "text-lg px-6" },
},
defaultVariants: { variant: "primary", size: "md" },
});
type ButtonProps = {
variant?: "primary" | "danger";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
className?: string;
};
function Button({ variant, size, children, className, ...props }: ButtonProps) {
return (
<button
className={button({ variant, size, className })}
{...props}
>
{children}
</button>
);
}
// TypeScript biết variant và size hợp lệ là gì
<Button variant="danger" size="lg">Delete</Button>
<Button variant="invalid">Error</Button> // ❌ compile error#3. VariantProps — Derive Types Tự Động
VariantProps extract TypeScript type trực tiếp từ CVA config — không cần viết type thủ công.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { cva, type VariantProps } from "class-variance-authority";
import { type ComponentPropsWithoutRef } from "react";
const button = cva("rounded font-medium", {
variants: {
variant: {
primary: "bg-blue-500 text-white",
secondary: "bg-gray-200 text-black",
danger: "bg-red-500 text-white",
ghost: "bg-transparent border",
},
size: {
sm: "text-sm px-2 py-1",
md: "text-base px-4 py-2",
lg: "text-lg px-6 py-3",
},
fullWidth: {
true: "w-full",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
// Derive tự động — không duplicate
type ButtonVariants = VariantProps<typeof button>;
// → { variant?: "primary" | "secondary" | "danger" | "ghost";
// size?: "sm" | "md" | "lg";
// fullWidth?: true }
// Kết hợp với HTML button props
type ButtonProps = ButtonVariants & ComponentPropsWithoutRef<"button">;
function Button({ variant, size, fullWidth, children, className, ...props }: ButtonProps) {
return (
<button
className={button({ variant, size, fullWidth, className })}
{...props}
>
{children}
</button>
);
}
// Hoàn toàn type-safe
<Button variant="secondary" size="sm" fullWidth disabled onClick={handleClick}>
Cancel
</Button>Lợi ích của VariantProps:
- Thêm variant mới vào CVA config → type tự cập nhật ngay
- Không bao giờ bị out-of-sync giữa config và type
- IDE tự complete danh sách variants hợp lệ
#4. Compound Variants — Kết Hợp Nhiều Variant
CVA hỗ trợ compoundVariants — class chỉ áp dụng khi nhiều variant cùng match:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const button = cva("rounded font-medium", {
variants: {
variant: { primary: "bg-blue-500", outline: "bg-transparent border" },
size: { sm: "text-sm", lg: "text-lg" },
},
compoundVariants: [
// Chỉ khi variant="outline" VÀ size="lg" → thêm class này
{
variant: "outline",
size: "lg",
className: "border-2 font-bold",
},
// Chỉ khi variant="primary" VÀ size="sm"
{
variant: "primary",
size: "sm",
className: "shadow-sm",
},
],
defaultVariants: { variant: "primary", size: "sm" },
});#5. Kết Hợp CVA và Zod — Single Source of Truth
CVA và Zod theo cùng nguyên tắc: định nghĩa một lần, dùng lại ở mọi nơi. Khi kết hợp, một array as const trở thành source of truth cho cả CVA config lẫn Zod schema.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { z } from "zod";
import { cva, type VariantProps } from "class-variance-authority";
// Bước 1: Khai báo variants một lần duy nhất
const BADGE_STATUSES = ["active", "inactive", "pending", "error"] as const;
type BadgeStatus = typeof BADGE_STATUSES[number];
// → "active" | "inactive" | "pending" | "error"
// Bước 2: CVA dùng type này cho class mapping
const badge = cva("inline-flex items-center rounded-full px-2 py-1 text-xs font-medium", {
variants: {
status: {
active: "bg-green-100 text-green-800",
inactive: "bg-gray-100 text-gray-600",
pending: "bg-yellow-100 text-yellow-800",
error: "bg-red-100 text-red-800",
} satisfies Record<BadgeStatus, string>, // đảm bảo không thiếu case nào
},
});
// Bước 3: Zod schema dùng cùng array — không hardcode lại
const badgeStatusSchema = z.enum(BADGE_STATUSES);
const userSchema = z.object({
name: z.string(),
status: badgeStatusSchema,
});
type User = z.infer<typeof userSchema>;
// → { name: string; status: "active" | "inactive" | "pending" | "error" }Kết quả: Khi thêm "archived" vào BADGE_STATUSES, TypeScript ngay lập tức báo lỗi tại satisfies Record<BadgeStatus, string> nếu bạn quên thêm class cho variant mới — và Zod schema tự include "archived" mà không cần sửa gì thêm.
#6. Tích Hợp với cn() / clsx
CVA kết hợp tốt với clsx hoặc tailwind-merge để merge class từ nhiều nguồn:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { cva, type VariantProps } from "class-variance-authority";
import { type ComponentPropsWithoutRef } from "react";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx";
// Utility function phổ biến trong cộng đồng
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const card = cva("rounded-lg border bg-white shadow-sm", {
variants: {
padding: {
none: "",
sm: "p-3",
md: "p-5",
lg: "p-8",
},
},
defaultVariants: { padding: "md" },
});
type CardProps = VariantProps<typeof card> &
ComponentPropsWithoutRef<"div">;
function Card({ padding, className, ...props }: CardProps) {
return (
<div
// twMerge đảm bảo class từ className override đúng cách
className={cn(card({ padding }), className)}
{...props}
/>
);
}
// className override — twMerge xử lý conflict tự động
<Card padding="lg" className="shadow-xl bg-gray-50">
content
</Card>CVA giải quyết đúng một vấn đề: quản lý CSS class variants theo cách type-safe và không lặp lại. Khi kết hợp với VariantProps, bạn có design system với single source of truth hoàn chỉnh — thêm variant mới chỉ cần sửa một chỗ trong CVA config, type và component props tự cập nhật theo. Đây là cách scale design system mà không tích lũy technical debt.