Bạn đã bao giờ nhìn vào một component Button với chục if/else để gán class CSS chưa? Thêm variant mới là thêm bug. Đổi tên một class là sửa năm chỗ. Và TypeScript hoàn toàn không biết variant nào hợp lệ. Đây là vấn đề rất phổ biến khi scale design system trong React.
Class Variance Authority (CVA) giải quyết đúng bài toán này: định nghĩa variants một lần, tự động ghép ra đúng chuỗi class, và TypeScript biết chính xác prop nào hợp lệ. Bạn không viết type thủ công — type được derive tự động từ config. Bài này dùng CVA 0.7.1 (bản mới nhất) với TypeScript ở chế độ strict.
1. Vấn đề khi không có CVA
Cách quản lý variants thủ công nhanh chóng trở nên khó maintain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 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>;
}Ba vấn đề lộ ra ngay:
- Không có type → TypeScript không báo gì khi bạn viết
variant="invalid". - Thêm variant
"outline"→ phải sửa cả function lẫn type khai báo riêng ở nơi khác. - Chuỗi class dễ bị duplicate hoặc conflict mà không ai phát hiện.
2. CVA cơ bản
cva nhận base classes và một config variants, trả về một function. Gọi function đó với các variant, bạn nhận lại chuỗi class hoàn chỉnh:
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 { 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",
},
}
);
// Base + variant + size được ghép theo đúng thứ tự khai báo.
// Bấm Run để xem chuỗi class thực tế:
console.log(button({ variant: "danger", size: "lg" }));
console.log(button()); // không truyền gì → dùng defaultVariants (primary + md)Bấm Run ngay dưới block: base classes luôn nằm đầu chuỗi, còn button() không truyền gì thì defaultVariants được áp dụng. Còn khi truyền sai variant, lỗi hiện ra ngay lúc compile:
1
2
3
4
5
6
7
const button = cva("rounded", {
variants: { variant: { primary: "bg-blue-500", danger: "bg-red-500" } },
});
button({ variant: "invalid" });
// ❌ error TS2322: Type '"invalid"' is not assignable to type
// '"danger" | "primary" | null | undefined'.Tích hợp vào React component chỉ là truyền props vào function:
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
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 errorNhưng để ý ButtonProps ở trên vẫn khai báo tay variant?: "primary" | "danger" — trùng lặp với config CVA. Thêm variant mới, bạn phải sửa hai chỗ. Phần tiếp theo xử lý đúng vấn đề này.
3. VariantProps — derive types tự động
VariantProps extract type trực tiếp từ CVA config, nên bạn không bao giờ phải khai báo lại danh sách variant bằng tay:
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
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" | null;
// size?: "sm" | "md" | "lg" | null;
// fullWidth?: boolean | null }
// Ghép thêm mọi prop chuẩn của <button> (onClick, disabled, type, ...)
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ưu ý: variant kiểu boolean
Khi một variant chỉ có key true (như fullWidth), CVA infer type của nó là fullWidth?: boolean, không phải fullWidth?: true. Vì vậy fullWidth={false} vẫn hợp lệ — nó chỉ đơn giản là không thêm class w-full nào. Đây là lý do <Button fullWidth> (viết tắt của fullWidth={true}) hoạt động đúng như một prop boolean bình thường.
Lợi ích của VariantProps:
- Thêm variant mới vào CVA config → type tự cập nhật ngay, không cần sửa nơi nào khác.
- Config và type không bao giờ bị out-of-sync.
- IDE tự complete danh sách variant hợp lệ khi bạn gõ.
4. Compound variants — kết hợp nhiều variant
Có những class chỉ nên xuất hiện khi nhiều variant cùng match một lúc — ví dụ nút outline cỡ lg cần viền dày hơn. compoundVariants giải quyết đúng trường hợp này:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { cva } from "class-variance-authority";
const button = cva("rounded font-medium", {
variants: {
variant: { primary: "bg-blue-500 text-white", outline: "bg-transparent border" },
size: { sm: "text-sm px-2", lg: "text-lg px-6" },
},
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" },
});
// Bấm Run và so sánh hai dòng: chỉ combo khớp mới nhận class compound
console.log(button({ variant: "outline", size: "lg" })); // có "border-2 font-bold"
console.log(button({ variant: "outline", size: "sm" })); // KHÔNG có "border-2 font-bold"Chỉ combo đúng cả hai điều kiện mới nhận thêm class. Đây là cách gọn nhất để xử lý các trường hợp giao nhau mà không phải nhét logic điều kiện vào từng variant.
5. Kết hợp CVA và Zod — single source of truth
CVA và Zod theo cùng một 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 (phần hiển thị) lẫn Zod schema (phần validate).
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
import { z } from "zod";
import { cva } from "class-variance-authority";
// Bước 1: Khai báo danh sách status 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 map từng status sang class
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>, // ép phải đủ mọi case
},
});
// Bước 3: Zod schema dùng lại chính array đó — không hardcode lại
const userSchema = z.object({
name: z.string(),
status: z.enum(BADGE_STATUSES),
});
type User = z.infer<typeof userSchema>;
// → { name: string; status: "active" | "inactive" | "pending" | "error" }
// Cùng một status vừa validate được bằng Zod, vừa ra đúng class bằng CVA:
const user: User = userSchema.parse({ name: "An", status: "pending" });
console.log("user: ", user);
console.log("class:", badge({ status: user.status }));Giá trị nằm ở dòng satisfies Record<BadgeStatus, string>. Khi bạn thêm "archived" vào BADGE_STATUSES nhưng quên thêm class tương ứng, TypeScript báo lỗi ngay:
1
2
3
4
5
const BADGE_STATUSES = ["active", "inactive", "pending", "error", "archived"] as const;
// ...
// ❌ error TS2741: Property 'archived' is missing in type
// '{ active: string; inactive: string; pending: string; error: string; }'
// but required in type 'Record<"active" | "archived" | ... , string>'.Cùng lúc đó, Zod schema tự động include "archived" mà bạn không phải sửa gì thêm. Một chỗ khai báo, cả class mapping lẫn validation đều cập nhật theo.
6. Tích hợp với cn() / clsx
Trong thực tế bạn thường cho phép override class từ ngoài qua prop className. Ghép trực tiếp bằng chuỗi sẽ để lại cả class cũ lẫn class mới, gây conflict. tailwind-merge xử lý đúng: class đến sau thắng class cùng nhóm đến trước.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 phổ biến: clsx gộp điều kiện, twMerge khử conflict Tailwind
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) {
// cn() gộp class từ CVA với className truyền ngoài rồi khử conflict
return <div className={cn(card({ padding }), className)} {...props} />;
}Điểm mấu chốt là cn(). Bấm Run block dưới để thấy khác biệt giữa nối chuỗi thường (clsx) và cn() khi className ghi đè class có sẵn:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
import { clsx } from "clsx";
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" },
});
const base = card({ padding: "lg" });
const override = "shadow-xl bg-gray-50"; // muốn thay bg và shadow của base
// Nối thường: bg-white + bg-gray-50 và shadow-sm + shadow-xl cùng tồn tại
console.log("clsx:", clsx(base, override));
// twMerge: class đến sau thắng → bg-white và shadow-sm bị loại bỏ
console.log("cn():", twMerge(clsx(base, override)));Nhìn vào console: với clsx, cả bg-white lẫn bg-gray-50 (và shadow-sm lẫn shadow-xl) cùng tồn tại — kết quả phụ thuộc thứ tự CSS, đúng loại bug khó tìm. cn() gọi twMerge để loại bg-white, shadow-sm cũ đi, chỉ giữ class ghi đè.
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. 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ỉ sửa một chỗ trong config, còn type và component props tự cập nhật theo. Thêm cn() để override an toàn và một array as const chia sẻ với Zod, bạn scale được design system mà không tích lũy technical debt. Hãy thử refactor component Button đầy if/else trong dự án của bạn sang CVA nhé.