jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ếnNên đưa vào đâuVí dụ
Tùy biến có ý nghĩa sản phẩmProp/variant có typevariant="destructive", size="icon"
Tùy biến layout tại nơi dùngclassName override cuối cùngclassName="w-full md:w-auto"
Tùy biến semantic/behaviorHTML prop nativedisabled, aria-invalid, type="submit"
Tùy biến đổi element renderasChild/SlotButton 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ơn red={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 @applykhô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 inputVí dụKết quảQuy tắc
Stringclsx('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ỏ
Objectclsx({ a: true, b: false })'a'key có value truthy được giữ
Mảngclsx(['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ẫnclsx('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 clsx KHÔNG làm: nó chỉ nối chuỗi. Nó không biết gì về Tailwindclsx('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-4py-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-8hover:px-8, nhưng px-4 hover:px-8 thì giữ cả hai (khác trạng thái, không xung đột).

InputOutputVì sao
twMerge('px-2 px-4')px-4cùng nhóm padding-x
twMerge('p-4 px-8')p-4 px-8px-8 thu hẹp p-4 ở trục x — giữ cả hai, đúng ý
twMerge('px-8 p-4')p-4p-4 đứng sau bao trùm px-8
twMerge('text-sm text-lg')text-lgcùng nhóm font-size
twMerge('hover:bg-red-500 hover:bg-blue-500')hover:bg-blue-500cùng nhóm, cùng variant
twMerge('bg-red-500 hover:bg-blue-500')bg-red-500 hover:bg-blue-500khác variant → không xung đột
twMerge('block flex')flexcùng nhóm display

twMerge KHÔNG làm: nó không xử lý điều kiện &&/object (đó là việc clsx), 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 raOverride 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…) 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ầnVai 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
variantsBản đồ tên trục → { tên option → class }mỗi trục độc lập; gọi buttonVariants({ variant, size })
defaultVariantsOption mặc định khi prop undefinednhờ 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: basevariants đã 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ền size="sm" vẫn ghi đè default bình thường. Luôn đặt defaultVariants để 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.tsxnpx 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 ứngnhận đầy đủ prop native, không tự khai thiếu
VariantProps<typeof ...>type variant suy ra từ cva, không lặp union tay
defaultVariantscomponent 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-50trạ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ácgiữ semantic đúng, không nhồi <button><a /></button>
forwardRef nếu còn React 18/Radix cần refprimitive đị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ế — BadgeAlert 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ứngNguyên nhânCá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 CSSDùng cn(base, className), để className đứng sau cùng
Dùng clsxpx-4 px-8 vẫn còn cả haiclsx chỉ nối chuỗi, không hiểu TailwindBọc bằng twMerge → tức là dùng cn
cn('foo', 'foo') không gộp class thườngtwMerge chỉ khử class Tailwind, không khử class tuỳ ý trùngVô 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ắngLuô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 defaultVariantsLuôn khai báo defaultVariants cho mọi trục
compoundVariants không áp dụngClass viết ở class:/className: sai khoá, hoặc giá trị không khớp đủ mọi trụcCompound 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ử conBọ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, className của người gọi sau cùng — vì trong cn, “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-4px-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-6p-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-6p-6 đứng sau và bao trùm cả trục x lẫn y, nên nuốt cả px-4py-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 buttonVariantscompoundVariants để: khi variant="outline" 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 uppercase

cn(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-lgrounded-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@apply tái sinh đúng 4 nỗi đau (đặt tên, switch file, CSS chết, specificity) của Phần 1.
  • Component nhận className làm sống lại bẫy p-2 p-4: class thắng theo thứ tự trong file CSS sinh ra, không phải thứ tự trong chuỗi class — nên override “thủ công” không đáng tin.
  • clsx dự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-merge hiểu Tailwind, gom nhóm xung độtgiữ class đứng sau.
  • cn() = twMerge(clsx(...)): ghép cả hai theo đúng thứ tự — clsx trước (gộp điều kiện), twMerge sau (khử xung đột). Đặt className cuối cùng để người gọi luôn thắng.
  • cva biế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) + forwardRef chí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@custom-variant, và các công cụ hoàn thiện một setup pro.