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.

The classic objection to Tailwind — “but I’m repeating px-4 py-2 rounded-md … everywhere!” — has a real answer, and it is not @apply {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}. The answer is components: extract the markup once, reuse it everywhere {Câu trả lời là component: trích markup một lần, dùng lại mọi nơi}. This part teaches the exact toolchain shadcn/ui is built on — master it now and shadcn will feel obvious later {Phần này dạy đúng bộ công cụ mà shadcn/ui dựng trên đó — nắm chắc giờ thì shadcn sẽ thấy hiển nhiên sau này}.


1. First instinct: a component, not @apply {Bản năng đầu tiên: component, không phải @apply}

// Button.tsx — extract once, reuse everywhere
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>
  );
}

Why not @apply? Because it pulls you back into naming things and maintaining a parallel stylesheet — the very problem utilities solved {Sao không dùng @apply? Vì nó kéo bạn quay lại việc đặt tên và bảo trì một stylesheet song song — đúng vấn đề mà utility đã giải quyết}. Use @apply only for tiny, truly-global primitives {Chỉ dùng @apply cho vài primitive toàn cục thật nhỏ}. For everything else: components {Còn lại: component}.


2. The problem with overridable components {Vấn đề của component cho phép override}

A real component must accept a className so callers can tweak it {Một component thật phải nhận className để người gọi tinh chỉnh}:

function Button({ className, children }) {
  return <button className={`px-4 py-2 bg-indigo-500 ${className}`}>{children}</button>;
}

// caller wants more padding + a different color
<Button className="px-8 bg-emerald-500">Save</Button>;

The rendered class is px-4 py-2 bg-indigo-500 px-8 bg-emerald-500 {Class render ra là px-4 py-2 bg-indigo-500 px-8 bg-emerald-500}. Now two px-* and two bg-* classes exist at once {Giờ hai class px-* và hai class bg-* cùng tồn tại}. Which wins? Not by source order in the markup — by order in the generated CSS file, which you don’t control {Cái nào thắng? Không theo thứ tự trong markup — mà theo thứ tự trong file CSS sinh ra, thứ bạn không kiểm soát}. The override is unreliable {Override trở nên không đáng tin}.


3. clsx + tailwind-merge = cn() {clsx + tailwind-merge = cn()}

Two tiny libraries fix this {Hai thư viện nhỏ sửa điều này}:

  • clsx — build class strings from conditions cleanly {dựng chuỗi class từ điều kiện một cách gọn}.
  • tailwind-merge — given a class string, remove earlier conflicting Tailwind classes so the last one wins {cho một chuỗi class, loại các class Tailwind xung đột đứng trước để cái cuối thắng}.
npm install clsx tailwind-merge

Combine them into one helper — this cn is in every shadcn project {Gộp thành một helper — cn này có trong mọi project shadcn}:

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Now the component is predictable {Giờ component có thể dự đoán}:

import { cn } from '@/lib/utils';

function Button({ className, ...props }) {
  return (
    <button
      className={cn('px-4 py-2 bg-indigo-500 rounded-md', className)}
      {...props}
    />
  );
}

cn('px-4 py-2 bg-indigo-500', 'px-8 bg-emerald-500')'py-2 rounded-md px-8 bg-emerald-500' — the conflicting px-4 and bg-indigo-500 are dropped {… — các px-4bg-indigo-500 xung đột bị loại bỏ}. Watch it resolve live in panel 2 below {Xem nó giải quyết trực tiếp ở panel 2 bên dưới}.

clsx also makes conditionals readable {clsx còn làm điều kiện dễ đọc}:

cn('rounded-md', isActive && 'ring-2 ring-indigo-400', disabled && 'opacity-50')

4. cva — type-safe variants {cva — variant type-safe}

Buttons have variants (primary, secondary, destructive) and sizes (sm, lg) {Nút có variant (primary, secondary, destructive) và size (sm, lg)}. Hand-rolling that with if/ternaries gets messy fast {Tự viết bằng if/ternary rối rất nhanh}. class-variance-authority (cva) is the standard solution {class-variance-authority (cva) là giải pháp chuẩn}:

npm install class-variance-authority
// button-variants.ts
import { cva, type VariantProps } from 'class-variance-authority';

export const buttonVariants = cva(
  // base — always applied
  '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: {
      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-border hover:bg-accent/10',
        ghost: 'hover:bg-accent/10',
      },
      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',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

export type ButtonVariants = VariantProps<typeof buttonVariants>;

The Button becomes thin, fully typed, and still overridable {Button trở nên mỏng, type đầy đủ, vẫn override được}:

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} />
  );
}
<Button>Default</Button>
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="outline" className="w-full">Full width outline</Button>

TypeScript now autocompletes variant and size and rejects typos {TypeScript giờ tự gợi ý variantsize và từ chối gõ sai}. This cva + cn pattern is the shape of every shadcn component — you’ll recognize it instantly in Part 9 {Mẫu cva + cn này chính là hình hài của mọi component shadcn — bạn sẽ nhận ra ngay ở Phần 9}.

Play with the exact factory below — pick a variant/size and watch cva resolve the className {Thử đúng factory đó bên dưới — chọn variant/size và xem cva resolve className}:


5. compoundVariants — the pro touch {compoundVariants — nét pro}

When combinations need special handling, use compoundVariants {Khi các tổ hợp cần xử lý riêng, dùng compoundVariants}:

cva('…', {
  variants: { variant: { outline: '…', ghost: '…' }, size: { icon: 'size-9 p-0' } },
  compoundVariants: [
    // only when variant=outline AND size=icon
    { variant: 'outline', size: 'icon', class: 'border-dashed' },
  ],
});

This keeps the common variants clean while still expressing the rare special cases {Cách này giữ variant phổ biến sạch mà vẫn diễn đạt được các ca đặc biệt hiếm}.


6. Exercises {Bài tập}

1. Write the cn() helper from memory and explain why clsx alone is not enough {Viết helper cn() từ trí nhớ và giải thích vì sao chỉ clsx là chưa đủ}.

Solution {Lời giải}
export const cn = (...i: ClassValue[]) => twMerge(clsx(i));

clsx only joins strings — it can’t tell that px-4 and px-8 conflict. tailwind-merge understands Tailwind and keeps only the last {clsx chỉ nối chuỗi — không biết px-4px-8 xung đột. tailwind-merge hiểu Tailwind và chỉ giữ cái cuối}.

2. Predict the output of cn('p-2 text-sm', 'p-6') {Đoán kết quả của cn('p-2 text-sm', 'p-6')}.

Solution {Lời giải}

'text-sm p-6'p-2 is dropped as it conflicts with p-6 {p-2 bị loại vì xung đột với p-6}.

3. Add a link variant (looks like an underlined text link) to the buttonVariants above {Thêm variant link (trông như link chữ gạch chân) vào buttonVariants ở trên}.

Solution {Lời giải}
variant: {
  // …
  link: 'text-indigo-500 underline-offset-4 hover:underline',
}

Stretch {Nâng cao}: in the merge panel, type an override that conflicts on rounded-* and confirm the earlier one gets struck through {trong panel merge, gõ override xung đột ở rounded-* và xác nhận cái trước bị gạch ngang}.


Key takeaways {Điểm chính}

  • Repetition is solved by components, not @apply {Lặp lại được giải quyết bằng component, không phải @apply}.
  • cn() = twMerge(clsx(...)): clsx for conditionals, tailwind-merge so overrides reliably win {cn() = twMerge(clsx(...)): clsx cho điều kiện, tailwind-merge để override thắng chắc chắn}.
  • cva gives type-safe variant + size props; compoundVariants handles combinations {cva cho prop variant + size type-safe; compoundVariants xử lý tổ hợp}.
  • This cva + cn shape is every shadcn component {Hình hài cva + cn này chính là mọi component shadcn}.

Next up {Tiếp theo}

Part 6 — Plugins & the ecosystem: official plugins (@tailwindcss/typography for prose, @tailwindcss/forms), tailwindcss-animate, writing a custom utility/variant in v4 with @utility and @custom-variant, and the tools that round out a pro setup {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}.