Mục lục
Xem mục lục
Chuẩn bị dependencies
Bốn thư viện thường đi cùng nhau trong pattern này:
npm install class-variance-authority clsx tailwind-merge @radix-ui/react-slot
class-variance-authority(cva): định nghĩa các biến thể style (variant, size) dưới dạng cấu hình, sinh type tự động.clsx: nối nhiều class lại với nhau, bỏ qua giá trịfalse/null/undefined.tailwind-merge: giải quyết xung đột khi hai class Tailwind cùng nhóm xuất hiện cùng lúc (ví dụpx-2vàpx-4), giữ lại class đứng sau.@radix-ui/react-slot: cho phép component “mượn” DOM element của con, nền tảng củaasChild.
Viết hàm cn()
clsx không giải quyết được xung đột Tailwind: clsx("px-2", "px-4") trả về cả hai class cùng lúc, kết quả phụ thuộc thứ tự CSS được nạp. cn() kết hợp clsx với twMerge để xử lý đúng việc này:
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
twMerge("px-2 px-4") trả về "px-4", chỉ giữ lại class đứng sau trong cùng nhóm thuộc tính. Đây là hàm nền tảng, mọi component trong bài dùng nó để merge class mặc định với className do người dùng truyền thêm.
Định nghĩa variant bằng cva
Thay vì viết if-else nối chuỗi class thủ công, cva định nghĩa các biến thể dưới dạng một cấu hình khai báo:
// button-variants.ts
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
Tham số đầu tiên là class áp dụng cho mọi biến thể (base class). variants khai báo từng nhóm biến thể độc lập, mỗi nhóm là một object key: class. defaultVariants chỉ định giá trị mặc định khi không truyền variant/size.
Gọi buttonVariants({ variant: "destructive", size: "sm" }) trả về đúng chuỗi class tương ứng, không cần viết bất kỳ điều kiện thủ công nào.
Component gốc chưa có gì đặc biệt
Ghép buttonVariants vào một component Button cơ bản:
// button.tsx
import { cn } from "@/lib/utils";
import { buttonVariants } from "./button-variants";
function Button({ className, variant, size, ...props }) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button };
buttonVariants({ variant, size, className }) không chỉ sinh class theo variant, mà còn tự động gọi cn() nội bộ để merge thêm className được truyền vào, đảm bảo style tùy biến từ bên ngoài luôn thắng style mặc định nhờ tailwind-merge.
Bản này chạy được, nhưng chưa có type cho props, chưa forward ref, chưa compose được với element khác. Các phần sau bổ sung dần từng phần.
Type-safe với VariantProps
cva cung cấp sẵn type VariantProps, lấy trực tiếp từ cấu hình buttonVariants, không cần định nghĩa lại union type variant/size thủ công:
import { type VariantProps } from "class-variance-authority";
import { buttonVariants } from "./button-variants";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
extends React.ButtonHTMLAttributes<HTMLButtonElement> kế thừa toàn bộ props gốc của <button>. VariantProps<typeof buttonVariants> tự sinh type variant/size từ chính config đã khai báo, thêm variant mới vào buttonVariants là type cập nhật theo ngay, không cần sửa chỗ khác.
Editor autocomplete đúng danh sách variant/size, và truyền sai giá trị (như variant="danger" thay vì "destructive") báo lỗi ngay lúc biên dịch, không phải đợi chạy app mới thấy nút mất style.
Forward ref đúng cách
Nhiều nơi cần truy cập trực tiếp DOM node của Button (focus thủ công, đo kích thước, tích hợp thư viện form). Với React 18 trở xuống, ref không tự động được truyền xuống qua props như các prop khác, cần React.forwardRef:
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
React.forwardRef<HTMLButtonElement, ButtonProps> khai báo kiểu node và props cho ref. displayName cần set thủ công vì function truyền vào forwardRef là anonymous, không set thì DevTools hiển thị tên component là ForwardRef thay vì Button.
Từ React 19, ref là một prop bình thường, có thể bỏ forwardRef và nhận trực tiếp trong props.
Thêm asChild với Radix Slot
Không có asChild, muốn Button trông giống nút nhưng thực chất là link, phải lồng hai thẻ tương tác vào nhau (<button><a>...</a></button>), sai chuẩn HTML và gây lỗi accessibility. Slot giải quyết việc này bằng cách gộp props của Button thẳng vào element con, không tự render thêm thẻ nào:
import { Slot } from "@radix-ui/react-slot";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
Chỉ cần thêm prop asChild và một dòng chọn Comp là Slot hay "button". Khi asChild là true, Slot clone children (yêu cầu đúng một element) với className, ref, và props của Button gộp vào, kết quả chỉ còn đúng một thẻ, mang style Button nhưng hành vi của element con.
Xử lý accessibility cơ bản
Base class trong buttonVariants đã có hai điểm accessibility đáng chú ý:
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2: hiện vòng focus khi điều hướng bằng bàn phím, ẩn khi click chuột, nhờ pseudo-class:focus-visible.disabled:opacity-50 disabled:pointer-events-none: dựa vào attributedisabledgốc, không cần state JS riêng, và tự ngăn mọi tương tác chứ không chỉ mỗi click.
Hai điểm này đến từ việc tận dụng đúng hành vi có sẵn của HTML/CSS, không cần thêm logic JavaScript.
Kết quả cuối cùng
// button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Ví dụ sử dụng
<Button>Mặc định</Button>
<Button variant="destructive">Xóa</Button>
<Button variant="outline" size="sm">Nhỏ</Button>
<Button size="icon"><TrashIcon /></Button>
// asChild: render ra <a>, không lồng <button><a>
<Button asChild>
<Link href="/dashboard">Đi tới Dashboard</Link>
</Button>
// className tùy biến vẫn merge đúng nhờ cn()
<Button className="w-full">Full width</Button>
// forward ref hoạt động bình thường
const ref = useRef<HTMLButtonElement>(null);
<Button ref={ref}>Focus được từ bên ngoài</Button>
Tổng kết
cn()(clsx+tailwind-merge) là nền tảng để merge class an toàn, giải quyết xung đột giữa class mặc định và class người dùng truyền thêm.cvatách style variant ra khỏi component, khai báo dạng cấu hình thay vì if-else, đồng thời sinh type tự động quaVariantProps.React.forwardRefcần thiết để truyềnrefxuống DOM node gốc (React 18 trở xuống), kèmdisplayNameđể debug dễ hơn.asChild/Slotcho phép component “mượn” DOM element của con, tránh lồng thẻ tương tác sai chuẩn HTML khi cần compose với element khác nhưLink.- Accessibility cơ bản (
focus-visible,disabled) tận dụng hành vi có sẵn của HTML/CSS, không cần logic JavaScript thêm.
Bốn kỹ thuật này (cn, cva, forwardRef, asChild) không chỉ áp dụng cho Button, mà là bộ khung chung cho phần lớn component UI cơ bản cần nhiều biến thể style và khả năng compose linh hoạt.