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-4 và bg-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 ý variant và size 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-4 và px-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(...)):clsxfor conditionals,tailwind-mergeso overrides reliably win {cn() = twMerge(clsx(...)):clsxcho điều kiện,tailwind-mergeđể override thắng chắc chắn}.cvagives type-safe variant + size props;compoundVariantshandles combinations {cvacho prop variant + size type-safe;compoundVariantsxử lý tổ hợp}.- This
cva+cnshape is every shadcn component {Hình hàicva+cnnà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 và @custom-variant, và các công cụ hoàn thiện một setup pro}.