Tailwind, Radix & shadcn/ui · Part 5 — Reusable Components: cn, clsx, tailwind-merge & cva
The moment classes repeat you need components — and the professional toolchain shadcn itself uses: clsx for conditionals, tailwind-merge to resolve conflicts, the cn() helper, and cva for type-safe variants. With a live variant factory.
Phản đối kinh điển với Tailwind — “nhưng tôi lặp px-4 py-2 rounded-md … khắp nơi!” — có câu trả lời thật, và đó không phải @apply. Câu trả lời là component: trích markup một lần, dùng lại mọi nơi. Nhưng “component nhận className” lại làm sống lại đúng cái bẫy p-2 p-4 từ Phần 1 — và đó là lý do tồn tại của cả một bộ ba công cụ: clsx, tailwind-merge, gói lại thành cn(), rồi cva để có variant type-safe. Đây chính xác là toolchain mà shadcn/ui dựng trên đó. Nắm chắc bốn thứ này bây giờ thì khi mở mã nguồn một component shadcn ở Phần 9, bạn sẽ thấy không có gì lạ cả.
Tư duy component API trước khi viết class
Một component reusable không chỉ là “gói lại một đoạn JSX”. Nó là một API nhỏ mà người khác sẽ dùng hàng trăm lần. Trước khi viết cva, hãy phân biệt bốn loại tùy biến:
| Loại tùy biến | Nên đưa vào đâu | Ví dụ |
|---|---|---|
| Tùy biến có ý nghĩa sản phẩm | Prop/variant có type | variant="destructive", size="icon" |
| Tùy biến layout tại nơi dùng | className override cuối cùng | className="w-full md:w-auto" |
| Tùy biến semantic/behavior | HTML prop native | disabled, aria-invalid, type="submit" |
| Tùy biến đổi element render | asChild/Slot | Button render thành <a> khi điều hướng |
Điểm phân biệt rất quan trọng: không biến mọi thứ thành prop. Nếu bạn thêm prop fullWidth, rounded, shadow, center, uppercase, component sẽ nhanh chóng thành một mini framework khó nhớ. Những gì là ý nghĩa domain/UI ổn định thì thành variant; những gì là ngoại lệ layout cục bộ thì để className giải quyết.
Tiêu chuẩn senior: component API nên ít, rõ nghĩa, và khó dùng sai.
variant="destructive"tốt hơnred={true}vì nó nói ý định, không khóa vào màu hiện tại.
1. Bản năng đầu tiên: component, không phải @apply
Khi một cụm class lặp lại, bản năng sai là gom chúng vào một tên class bằng @apply:
/* ❌ Cách kéo bạn quay lại CSS truyền thống */
.btn {
@apply rounded-md bg-indigo-500 px-4 py-2 text-white hover:bg-indigo-600;
}
<button class="btn">Save</button>
Trông gọn, nhưng nó tái sinh đúng 4 nỗi đau mà utility-first đã xoá ở Phần 1: bạn lại phải đặt tên (.btn hay .button-primary?), lại switch file (sửa nút phải mở file CSS), lại có CSS chết (xoá nút trong markup nhưng .btn còn nằm đó), và lại bắt đầu chuỗi specificity. @apply chỉ nên dùng cho vài primitive toàn cục cực nhỏ (ví dụ reset cho body), tuyệt đối không phải cho component.
Bản năng đúng là trích thành component — đơn vị tái sử dụng tự nhiên của React:
// Button.tsx — trích một lần, dùng lại mọi nơi
export function Button({ children }: { children: React.ReactNode }) {
return (
<button className="rounded-md bg-indigo-500 px-4 py-2 text-white hover:bg-indigo-600">
{children}
</button>
);
}
Class vẫn nằm ngay trong markup (không file CSS song song), nhưng giờ có một chỗ duy nhất để sửa. Đó là toàn bộ lợi ích của @apply mà không kèm nhược điểm.
Quy tắc ngón tay cái: lặp markup → tách component. Lặp một câu CSS thật sự nguyên thuỷ, toàn cục → may ra mới
@apply. 95% trường hợp là vế đầu.
2. Component “có thể override” làm sống lại bẫy p-2 p-4
Một component dùng được trong đời thật phải cho người gọi tinh chỉnh — tức nhận thêm className từ ngoài:
function Button({ className, children }) {
// ❌ nối chuỗi thẳng
return <button className={`px-4 py-2 bg-indigo-500 ${className}`}>{children}</button>;
}
// người gọi muốn nhiều padding hơn + đổi màu
<Button className="px-8 bg-emerald-500">Save</Button>;
Chuỗi class render ra là:
px-4 py-2 bg-indigo-500 px-8 bg-emerald-500
└───┬──┘ └───┬──┘ └──────┬─────┘
base override override
Giờ hai class px-* và hai class bg-* cùng tồn tại trên một phần tử. Cái nào thắng? Nhớ lại bài học cốt lõi từ Phần 1: không phải class viết sau trong chuỗi class thắng — mà class đứng sau trong file CSS sinh ra thắng. Và thứ tự trong file CSS do Tailwind quyết định lúc build, bạn không kiểm soát.
Hệ quả thực tế: có khi px-8 của người gọi thắng, có khi px-4 của base thắng — tuỳ Tailwind sắp .px-4 hay .px-8 trước trong output. Override trở nên không đáng tin. Đây đúng là bẫy p-2 p-4 ở Phần 1, chỉ phóng to lên quy mô component.
Ta cần một thứ: trước khi giao chuỗi class cho DOM, loại bỏ các class Tailwind xung đột, chỉ giữ cái cuối cùng — để “cuối cùng” mang nghĩa vị trí trong chuỗi, điều bạn kiểm soát được. Đó chính xác là việc của tailwind-merge. Nhưng trước nó, ta cần clsx để dựng chuỗi đó cho gọn.
3. clsx — dựng chuỗi class có điều kiện
clsx (và người anh em gần như giống hệt classnames) giải quyết một việc nhỏ nhưng lặp đi lặp lại: biến một mớ điều kiện thành một chuỗi class sạch, tự bỏ qua false, null, undefined, 0, ''.
npm install clsx
Không có clsx, code điều kiện trông thế này — đầy && và khoảng trắng thừa:
// ❌ thủ công: dễ ra " ring-2 opacity-50 " với khoảng trắng lạc, dễ sót
const cls =
'rounded-md ' +
(isActive ? 'ring-2 ring-indigo-400 ' : '') +
(disabled ? 'opacity-50' : '');
Với clsx, cùng logic gọn và an toàn:
import clsx from 'clsx';
clsx('rounded-md', isActive && 'ring-2 ring-indigo-400', disabled && 'opacity-50');
// isActive=true, disabled=false → "rounded-md ring-2 ring-indigo-400"
clsx nhận nhiều kiểu input và chuẩn hoá hết về một chuỗi. Đây là bảng tra đầy đủ — thuộc bảng này là dùng được 100% clsx:
| Kiểu input | Ví dụ | Kết quả | Quy tắc |
|---|---|---|---|
| String | clsx('a', 'b') | 'a b' | nối, cách nhau 1 space |
Điều kiện && | clsx(on && 'a') | 'a' nếu on, ngược lại '' | falsy bị bỏ |
| Object | clsx({ a: true, b: false }) | 'a' | key có value truthy được giữ |
| Mảng | clsx(['a', cond && 'b']) | flatten như đối số | đệ quy, lồng sâu được |
| Falsy bất kỳ | clsx(null, undefined, 0, '', false) | '' | tất cả bị bỏ qua |
| Trộn lẫn | clsx('a', { b: x }, ['c', y && 'd']) | gộp tất cả | tự do trộn các kiểu trên |
Object form đặc biệt hợp khi key chính là tên class:
clsx('badge', {
'bg-green-100 text-green-800': status === 'success',
'bg-red-100 text-red-800': status === 'error',
'bg-zinc-100 text-zinc-700': status === 'idle',
});
Cái
clsxKHÔNG làm: nó chỉ nối chuỗi. Nó không biết gì về Tailwind —clsx('px-4', 'px-8')ra thẳng'px-4 px-8', cả hai vẫn còn nguyên. Việc khử trùng lặp là của bước tiếp theo.
4. tailwind-merge — vì sao clsx chưa đủ
tailwind-merge (hàm twMerge) làm đúng một việc mà clsx cố tình không làm: hiểu ngữ nghĩa Tailwind, nhận ra hai class điều khiển cùng một thuộc tính CSS, và chỉ giữ class đứng sau cùng trong chuỗi.
npm install tailwind-merge
import { twMerge } from 'tailwind-merge';
twMerge('px-4 px-8'); // → 'px-8' (cùng padding-x, giữ cuối)
twMerge('p-2 p-4'); // → 'p-4' (đúng bẫy Phần 1!)
twMerge('bg-indigo-500 bg-emerald-500'); // → 'bg-emerald-500'
twMerge('px-4 py-2 bg-indigo-500 px-8 bg-emerald-500');
// → 'py-2 px-8 bg-emerald-500' (px-4, bg-indigo-500 bị nuốt)
Cơ chế bên dưới: “conflict group”
twMerge không chỉ so khớp tiền tố chuỗi (nếu vậy nó đã không biết p-4 đè được px-4 và py-4). Nó mang sẵn một bản đồ tri thức về Tailwind: mỗi utility được gán vào một nhóm xung đột (conflict group) theo thuộc tính CSS mà nó tác động. Khi quét chuỗi từ trái sang phải, nếu gặp hai class cùng nhóm, class trước bị loại.
Chuỗi vào: px-4 py-2 bg-indigo-500 px-8 bg-emerald-500
│ │ │ │ │
Nhóm: padding-x padding-y bg-color padding-x bg-color
│ │
└──────── cùng nhóm ───────────┘ → px-4 bị loại
(giữ px-8, cái sau)
Kết quả: py-2 px-8 bg-emerald-500
Quan trọng: nó hiểu quan hệ bao trùm. p-4 cùng nhóm “padding” sẽ ghi đè cả px-2, py-2, pt-2… vì p-* chi phối toàn bộ padding. Nó cũng hiểu variant: hover:px-4 hover:px-8 → hover:px-8, nhưng px-4 hover:px-8 thì giữ cả hai (khác trạng thái, không xung đột).
| Input | Output | Vì sao |
|---|---|---|
twMerge('px-2 px-4') | px-4 | cùng nhóm padding-x |
twMerge('p-4 px-8') | p-4 px-8 | px-8 thu hẹp p-4 ở trục x — giữ cả hai, đúng ý |
twMerge('px-8 p-4') | p-4 | p-4 đứng sau bao trùm px-8 |
twMerge('text-sm text-lg') | text-lg | cùng nhóm font-size |
twMerge('hover:bg-red-500 hover:bg-blue-500') | hover:bg-blue-500 | cùng nhóm, cùng variant |
twMerge('bg-red-500 hover:bg-blue-500') | bg-red-500 hover:bg-blue-500 | khác variant → không xung đột |
twMerge('block flex') | flex | cùng nhóm display |
twMergeKHÔNG làm: nó không xử lý điều kiện&&/object (đó là việcclsx), và nó không loại trùng class không-Tailwind (twMerge('foo foo')→'foo foo'). Hai công cụ bù trừ hoàn hảo cho nhau — và đó là lý do ta luôn ghép chúng lại.
5. cn() — helper kinh điển ghép cả hai
Một component cần cả hai: clsx để dựng chuỗi có điều kiện, rồi twMerge để khử xung đột Tailwind. Thứ tự bắt buộc là clsx trước, twMerge sau — vì twMerge cần một chuỗi phẳng đã sẵn sàng để quét. Gói lại thành cn() (viết tắt của classnames):
// lib/utils.ts — file này có trong MỌI project shadcn, gần như nguyên văn
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Đọc từ trong ra ngoài: clsx(inputs) gộp mọi điều kiện/object/mảng thành một chuỗi phẳng, rồi twMerge(...) quét chuỗi đó và loại các class Tailwind xung đột đứng trước. Pipeline:
cn('px-4 py-2 bg-indigo-500', isWide && 'px-8', 'bg-emerald-500')
│
▼ clsx() — gộp điều kiện, bỏ falsy
'px-4 py-2 bg-indigo-500 px-8 bg-emerald-500'
│
▼ twMerge() — khử xung đột, giữ cái sau
'py-2 px-8 bg-emerald-500'
Giờ component trở nên dự đoán được: base trước, className của người gọi sau — và vì người gọi đứng sau trong đối số, override của họ luôn thắng:
import { cn } from '@/lib/utils';
function Button({ className, ...props }: React.ComponentProps<'button'>) {
return (
<button
className={cn('px-4 py-2 bg-indigo-500 rounded-md', className)}
{...props}
/>
);
}
// <Button className="px-8 bg-emerald-500" /> → 'py-2 rounded-md px-8 bg-emerald-500'
Bảng so sánh ba cách nối class — thấy ngay vì sao chỉ cn() là đúng:
| Cách viết | <Button className="px-8 bg-emerald-500"> cho ra | Override có thắng chắc không? |
|---|---|---|
Template string `px-4 ${className}` | px-4 py-2 bg-indigo-500 px-8 bg-emerald-500 | ❌ tuỳ thứ tự trong file CSS |
clsx('px-4 …', className) | px-4 py-2 bg-indigo-500 px-8 bg-emerald-500 | ❌ clsx không khử Tailwind |
cn('px-4 …', className) | py-2 rounded-md px-8 bg-emerald-500 | ✅ luôn thắng, vì đứng sau |
clsx (bên trong cn) cũng giúp điều kiện sạch sẽ ngay trong component:
cn(
'rounded-md px-4 py-2',
isActive && 'ring-2 ring-indigo-400',
disabled && 'opacity-50 pointer-events-none',
className, // người gọi luôn được nói lời cuối
)
Xem cn() khử xung đột trực tiếp ở panel 2 của playground bên dưới (mục 8).
6. cva — variant type-safe, hết cảnh if/ternary
cn() giải quyết override, nhưng chưa giải quyết variant. Một nút thật có nhiều biến thể trực giao: variant (primary, secondary, destructive…) và size (sm, default, lg). Viết tay bằng if/ternary phình rất nhanh và dễ sai:
// ❌ tổ hợp variant × size → mê cung ternary
const classes =
'inline-flex items-center font-medium ' +
(variant === 'destructive' ? 'bg-red-600 text-white ' : 'bg-indigo-500 text-white ') +
(size === 'lg' ? 'h-12 px-6 ' : 'h-10 px-4 ');
class-variance-authority (cva) biến mớ này thành một bảng khai báo — và tặng kèm type miễn phí:
npm install class-variance-authority
Giải phẫu một cva
// button-variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// ① BASE — luôn áp dụng, bất kể variant nào
'inline-flex items-center justify-center gap-2 font-medium transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 ' +
'disabled:opacity-50 disabled:pointer-events-none',
{
// ② VARIANTS — mỗi trục là một nhóm lựa chọn loại trừ nhau
variants: {
variant: {
default: 'bg-indigo-500 text-white hover:bg-indigo-600',
secondary: 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700',
destructive: 'bg-red-600 text-white hover:bg-red-700',
outline: 'border border-zinc-300 bg-transparent hover:bg-zinc-100',
ghost: 'bg-transparent hover:bg-zinc-100',
link: 'bg-transparent text-indigo-500 underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-sm rounded-md',
default: 'h-10 px-4 text-sm rounded-lg',
lg: 'h-12 px-6 text-base rounded-xl',
icon: 'size-10 p-0 rounded-lg',
},
},
// ③ DEFAULT VARIANTS — dùng khi người gọi không truyền
defaultVariants: { variant: 'default', size: 'default' },
},
);
// ④ TYPE suy ra từ chính cva — không khai báo lại tay
export type ButtonVariants = VariantProps<typeof buttonVariants>;
Bốn phần, đọc kỹ từng cái:
| Phần | Vai trò | Lưu ý |
|---|---|---|
| base (đối số 1) | Chuỗi class áp dụng cho mọi biến thể | layout, transition, focus ring, disabled… đặt ở đây |
variants | Bản đồ tên trục → { tên option → class } | mỗi trục độc lập; gọi buttonVariants({ variant, size }) |
defaultVariants | Option mặc định khi prop undefined | nhờ nó buttonVariants() rỗng vẫn ra nút hợp lệ |
VariantProps<typeof …> | Suy type prop từ cva | { variant?: 'default' | …; size?: 'sm' | … } tự động |
Gọi cva ra một hàm. Truyền lựa chọn vào, nhận chuỗi class đã ghép base + variant + size:
buttonVariants();
// → "inline-flex … bg-indigo-500 text-white hover:bg-indigo-600 h-10 px-4 text-sm rounded-lg"
// (base + default + default)
buttonVariants({ variant: 'destructive', size: 'lg' });
// → "inline-flex … bg-red-600 text-white hover:bg-red-700 h-12 px-6 text-base rounded-xl"
buttonVariants({ size: 'icon' });
// → "inline-flex … bg-indigo-500 … size-10 p-0 rounded-lg" (variant lấy default)
VariantProps<typeof buttonVariants> là mảnh ghép type-safe: nó đọc ngược định nghĩa cva và sinh ra type prop. Thêm một variant mới vào object → type tự cập nhật, TypeScript tự biết. Bạn không bao giờ phải viết tay 'default' | 'destructive' | … ở hai nơi.
7. compoundVariants & defaultVariants — xử lý tổ hợp
Đôi khi một class chỉ nên áp dụng khi nhiều trục cùng đạt một giá trị — không thuộc riêng variant nào hay size nào. Đó là việc của compoundVariants:
export const buttonVariants = cva('… base …', {
variants: {
variant: { default: '…', outline: 'border border-zinc-300', ghost: '…' },
size: { default: 'h-10 px-4', icon: 'size-10 p-0' },
},
compoundVariants: [
// CHỈ khi variant=outline VÀ size=icon mới thêm viền nét đứt
{ variant: 'outline', size: 'icon', class: 'border-dashed' },
// có thể match nhiều giá trị: áp cho cả hai size khi variant=ghost
{ variant: 'ghost', size: ['default', 'icon'], class: 'shadow-none' },
],
defaultVariants: { variant: 'default', size: 'default' },
});
Mỗi phần tử compoundVariants là một điều kiện AND: tất cả khoá phải khớp thì class (hoặc className) mới được nối thêm. Giá trị có thể là mảng để khớp nhiều option cùng lúc.
buttonVariants({ variant: 'outline', size: 'icon' })
→ base + outline + icon + 'border-dashed' ✅ khớp compound đầu
buttonVariants({ variant: 'outline', size: 'default' })
→ base + outline + default ✗ không khớp (size ≠ icon)
Thứ tự nối của cva: base → variants đã chọn → compoundVariants khớp. Vì compound đứng sau, class của nó thắng class cùng nhóm trong base/variant (đúng quy tắc “sau thắng” mà twMerge cũng tôn trọng nếu bạn còn bọc thêm cn).
defaultVariants≠ giá trị bắt buộc. Nó chỉ điền vào khi prop làundefined. Người gọi truyềnsize="sm"vẫn ghi đè default bình thường. Luôn đặtdefaultVariantsđểbuttonVariants()(gọi rỗng, ví dụ khi render không kiểm soát) không bao giờ ra nút “trần”.
8. Dựng Button production — từng bước
Giờ ghép tất cả: cva lo variant, cn lo override của người gọi, cộng vài tinh chỉnh chuẩn shadcn. Xây dần từng lớp.
Bước 1 — component mỏng, ghép cva + cn. cva ra chuỗi variant, cn cho phép className ngoài cùng thắng:
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonVariants } from './button-variants';
type ButtonProps = React.ComponentProps<'button'> & ButtonVariants;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
Vì sao phải bọc thêm cn quanh buttonVariants(...)? Vì buttonVariants không biết gì về className người gọi truyền vào — nó chỉ ghép base+variant+size. cn(buttonVariants(...), className) đặt className sau cùng rồi twMerge để override của họ luôn thắng:
<Button variant="default" className="bg-emerald-500">Save</Button>
buttonVariants({variant:'default'}) → '… bg-indigo-500 …'
cn('… bg-indigo-500 …', 'bg-emerald-500')
└ twMerge nuốt bg-indigo-500 ┘
→ '… bg-emerald-500 …' ✅ người gọi thắng
Bước 2 — asChild qua Radix Slot (mẫu shadcn). Đôi khi bạn muốn nút trông như nút nhưng render ra thẻ khác — ví dụ một <a> để điều hướng. Slot của Radix “tan biến” vào con của nó, chuyển toàn bộ class/prop xuống:
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonVariants } from './button-variants';
type ButtonProps = React.ComponentProps<'button'> &
ButtonVariants & { asChild?: boolean };
export function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
// render <button> bình thường
<Button variant="destructive" size="lg">Delete</Button>
// render <a> nhưng MANG class của Button — nhờ asChild + Slot
<Button asChild variant="link">
<a href="/docs">Đọc tài liệu</a>
</Button>
Bước 3 — forwardRef (nếu dùng React ≤ 18). Thư viện như Radix cần ref chạm tới DOM thật (để định vị tooltip, focus…). React 19 cho ref là prop thường nên có thể bỏ qua; với React 18 trở về trước, bọc forwardRef:
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonVariants } from './button-variants';
type ButtonProps = React.ComponentProps<'button'> &
ButtonVariants & { asChild?: boolean };
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = 'Button';
Đây chính xác là hình hài file button.tsx mà npx shadcn@latest add button đổ vào project bạn — cva cho variant, cn cho override, Slot cho asChild, forwardRef cho ref. Đến Phần 9 bạn sẽ thấy lại từng dòng.
Thử đúng factory đó bên dưới — chọn variant/size và xem cva resolve className, panel 2 cho thấy cn() khử xung đột:
Checklist cho một component shadcn-style
Khi bạn tự viết component cùng phong cách shadcn, checklist tối thiểu:
| Điều cần có | Vì sao |
|---|---|
React.ComponentProps<'button'> hoặc element tương ứng | nhận đầy đủ prop native, không tự khai thiếu |
VariantProps<typeof ...> | type variant suy ra từ cva, không lặp union tay |
defaultVariants | component render hợp lệ khi không truyền prop |
cn(variants(...), className) | override của người gọi thắng ổn định |
disabled:pointer-events-none disabled:opacity-50 | trạng thái disabled rõ cả hành vi lẫn thị giác |
focus-visible:* | keyboard user luôn thấy focus |
asChild khi component có thể render link/trigger khác | giữ semantic đúng, không nhồi <button><a /></button> |
forwardRef nếu còn React 18/Radix cần ref | primitive định vị/focus được node thật |
Điểm cuối hay bị xem nhẹ: semantic HTML quan trọng hơn thẻ bạn thấy tiện. Nếu hành động điều hướng, render <a> qua asChild; nếu submit form, render <button type="submit">. Style giống nhau không có nghĩa semantics giống nhau.
9. Recipe thực tế — Badge và Alert bằng cva
Cùng một khuôn cva + cn áp cho mọi component có biến thể. Hai ví dụ hay gặp nhất:
Badge
// badge.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
{
variants: {
variant: {
default: 'bg-indigo-100 text-indigo-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-amber-100 text-amber-800',
destructive: 'bg-red-100 text-red-800',
outline: 'border border-zinc-300 text-zinc-700',
},
},
defaultVariants: { variant: 'default' },
},
);
type BadgeProps = React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>;
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
<Badge>New</Badge>
<Badge variant="success">Paid</Badge>
<Badge variant="destructive">Overdue</Badge>
className sinh ra cho <Badge variant="success">:
inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-green-100 text-green-800
└──────────────────────── base ────────────────────────┘ └──── variant=success ────┘
Alert
Alert minh hoạ trường hợp một component có nhiều phần con (icon, title, description) cùng phản ứng theo variant. cva áp cho thẻ bao ngoài là đủ:
// alert.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm flex gap-3',
{
variants: {
variant: {
default: 'bg-white border-zinc-200 text-zinc-900',
info: 'bg-blue-50 border-blue-200 text-blue-900',
success: 'bg-green-50 border-green-200 text-green-900',
destructive: 'bg-red-50 border-red-200 text-red-900',
},
},
defaultVariants: { variant: 'default' },
},
);
type AlertProps = React.ComponentProps<'div'> & VariantProps<typeof alertVariants>;
export function Alert({ className, variant, children, ...props }: AlertProps) {
return (
<div role="alert" className={cn(alertVariants({ variant }), className)} {...props}>
{children}
</div>
);
}
export function AlertTitle({ className, ...props }: React.ComponentProps<'h5'>) {
return <h5 className={cn('mb-1 font-semibold leading-none', className)} {...props} />;
}
export function AlertDescription({ className, ...props }: React.ComponentProps<'p'>) {
return <p className={cn('text-sm opacity-90', className)} {...props} />;
}
<Alert variant="destructive">
<TriangleAlertIcon className="size-5" />
<div>
<AlertTitle>Thanh toán thất bại</AlertTitle>
<AlertDescription>Thẻ của bạn đã bị từ chối.</AlertDescription>
</div>
</Alert>
Để ý: AlertTitle/AlertDescription không cần cva (chỉ có một kiểu) — nhưng vẫn dùng cn(...) để người gọi override được. Quy luật chung: cva khi có biến thể, cn cho mọi component nhận className.
10. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Override className “lúc ăn lúc không” | Nối bằng template string `…${className}` — thắng/thua tuỳ thứ tự file CSS | Dùng cn(base, className), để className đứng sau cùng |
Dùng clsx mà px-4 px-8 vẫn còn cả hai | clsx chỉ nối chuỗi, không hiểu Tailwind | Bọc bằng twMerge → tức là dùng cn |
cn('foo', 'foo') không gộp class thường | twMerge chỉ khử class Tailwind, không khử class tuỳ ý trùng | Vô hại; nếu cần, tự lọc trùng trước |
Override không thắng dù đã dùng cn | Đặt sai thứ tự: cn(className, base) → base đứng sau lại thắng | Luôn cn(base, …, className) — người gọi cuối cùng |
| Thêm variant mới nhưng TS không gợi ý | Khai báo type prop bằng tay thay vì VariantProps<typeof …> | Để type suy ra từ cva, đừng viết union tay |
buttonVariants() ra nút “trần” | Quên defaultVariants | Luôn khai báo defaultVariants cho mọi trục |
compoundVariants không áp dụng | Class viết ở class:/className: sai khoá, hoặc giá trị không khớp đủ mọi trục | Compound là AND — mọi khoá phải khớp; dùng mảng để match nhiều option |
asChild lỗi “expected single child” | Slot chỉ nhận đúng một phần tử con | Bọc nội dung trong một thẻ duy nhất |
Hai gotcha số 1 và số 4 chiếm phần lớn lỗi override trong thực tế. Khẩu quyết: base trước,
classNamecủa người gọi sau cùng — vì trongcn, “sau cùng trong đối số” = “thắng”.
11. Bài tập
1. Viết helper cn() từ trí nhớ và giải thích vì sao chỉ clsx là chưa đủ.
Lời giải
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));clsx chỉ nối chuỗi và xử lý điều kiện — nó không biết px-4 và px-8 cùng tác động padding-x nên giữ cả hai. tailwind-merge hiểu Tailwind, gom vào nhóm xung đột và chỉ giữ class đứng sau.
2. Đoán kết quả của cn('p-2 text-sm', 'p-6').
Lời giải
text-sm p-6 — p-2 bị loại vì xung đột nhóm padding với p-6 (cái sau thắng); text-sm khác nhóm nên giữ nguyên.
3. Thêm variant link (trông như link chữ gạch chân) vào buttonVariants.
Lời giải
variant: {
// …
link: 'bg-transparent text-indigo-500 underline-offset-4 hover:underline',
}Không cần đổi gì khác — VariantProps tự cập nhật để TS gợi ý variant="link".
4. Đoán output của cn('px-4 py-2', 'p-6') và giải thích theo “nhóm xung đột”.
Lời giải
p-6 — p-6 đứng sau và bao trùm cả trục x lẫn y, nên nuốt cả px-4 và py-2. (So sánh: cn('p-6', 'px-4') → p-6 px-4, vì px-4 đứng sau chỉ thu hẹp trục x.)
5. Viết buttonVariants có compoundVariants để: khi variant="outline" và size="sm" thì thêm tracking-wide.
Lời giải
compoundVariants: [
{ variant: 'outline', size: 'sm', class: 'tracking-wide' },
],Chỉ tổ hợp đúng cả hai khoá mới được nối thêm tracking-wide.
6. Hoàn thiện Badge ở mục 9 thành component nhận cả className lẫn variant, rồi viết className sinh ra cho <Badge variant="warning" className="uppercase">.
Lời giải
inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-100 text-amber-800 uppercasecn(badgeVariants({ variant: 'warning' }), 'uppercase') — base + variant warning + uppercase của người gọi (không xung đột nhóm nào nên được giữ).
7. (Nâng cao) Trong panel merge của playground, gõ override xung đột ở rounded-* (ví dụ thêm rounded-full vào một nút đang rounded-lg) và xác nhận class trước bị gạch ngang trong output.
Lời giải
rounded-lg và rounded-full cùng nhóm border-radius; twMerge giữ cái đứng sau (rounded-full) và loại rounded-lg. Playground sẽ hiển thị rounded-lg bị gạch để minh hoạ bước khử xung đột.
Điểm chính
- Lặp markup được giải quyết bằng component, không phải
@apply—@applytái sinh đúng 4 nỗi đau (đặt tên, switch file, CSS chết, specificity) của Phần 1. - Component nhận
classNamelàm sống lại bẫyp-2 p-4: class thắng theo thứ tự trong file CSS sinh ra, không phải thứ tự trong chuỗiclass— nên override “thủ công” không đáng tin. clsxdựng chuỗi class có điều kiện (bỏ falsy, nhận string/object/mảng) nhưng không hiểu Tailwind;tailwind-mergehiểu Tailwind, gom nhóm xung đột và giữ class đứng sau.cn() = twMerge(clsx(...)): ghép cả hai theo đúng thứ tự —clsxtrước (gộp điều kiện),twMergesau (khử xung đột). ĐặtclassNamecuối cùng để người gọi luôn thắng.cvabiến variant × size thành bảng khai báo:base+variants+compoundVariants(AND nhiều trục) +defaultVariants;VariantProps<typeof …>suy type prop tự động — không viết union tay.- Mẫu
cva+cn+Slot(asChild) +forwardRefchính là hình hài mọi component shadcn — nắm giờ thì Phần 9 là hiển nhiên.
Tiếp theo
Phần 6 — Plugin & hệ sinh thái: plugin chính thức (@tailwindcss/typography cho prose, @tailwindcss/forms), tailwindcss-animate, tự viết utility/variant trong v4 với @utility và @custom-variant, và các công cụ hoàn thiện một setup pro.