Skip to content
Home
Go back

Xây dựng Button Component theo pattern shadcn/ui

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

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.

Lợi ích thực tế của type-safe

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ú ý:

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

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.


Share this post:

Next Post
RBAC: Từ lý thuyết đến triển khai production