Tailwind, Radix & shadcn/ui · Part 14 — Tailwind v4 Cheatsheet & Production Reference
Reference học và làm thật cho Tailwind v4: bản đồ utility, recipe layout/component, @theme design tokens, shadcn semantic tokens, gotchas và checklist debug production.
Một cheatsheet tốt không phải là bảng nhồi thật nhiều class vào một trang. Nó phải giúp bạn trả lời nhanh câu hỏi quan trọng hơn:
“Mình đang muốn UI làm điều gì, vậy nhóm utility nào giải quyết đúng việc đó?”
Đây là bài reference cuối series. Nếu Phần 1-12 là hành trình xây nền và Phần 13 đào sâu variant, bài này là bản đồ làm việc hằng ngày: mở ra khi bạn đang code, tra đúng nhóm, lấy recipe, hiểu vì sao nó đúng, rồi áp dụng vào component thật.
Sau bài này, một người mới không chỉ nhớ flex hay p-4, mà sẽ biết:
- nhìn một layout và tách nó thành display, flow, spacing, sizing, state;
- dùng Tailwind như một API của CSS chứ không copy class vô thức;
- dựng component theo token semantic kiểu shadcn thay vì hard-code màu;
- debug các lỗi “class không ăn”, “text làm vỡ flex”, “dark mode lệch”, “ring không hiện”;
- biết lúc nào dùng utility mặc định, lúc nào tạo token
@theme, lúc nào dùng arbitrary value.
Cheatsheet tương tác nằm ngay dưới đây. Nó gom 13 nhóm utility và 1 nhóm @theme design tokens. Bài viết này là lớp giải thích và recipe production đi kèm.
1. Cách tư duy đúng: Tailwind là API của CSS
Tailwind không thay CSS. Tailwind đặt một lớp API ngắn, nhất quán lên trên CSS.
<div class="bg-background flex items-center gap-3 rounded-md border p-4">
...
</div>
Đọc ngược về CSS:
| Utility | CSS / ý nghĩa |
|---|---|
flex | display: flex |
items-center | align-items: center |
gap-3 | khoảng cách con theo token spacing |
rounded-md | bán kính bo góc theo token radius |
border | border mặc định |
bg-background | màu nền semantic token |
p-4 | padding từ thang spacing |
Điểm quan trọng: utility của Tailwind chia làm hai nhóm lớn.
| Nhóm | Ví dụ | Tính chất |
|---|---|---|
| Static utility | flex, grid, relative, overflow-hidden, object-cover | Gần như giống nhau ở mọi project |
| Token-driven utility | bg-primary, p-4, rounded-lg, text-sm, shadow-md | Tồn tại và mang giá trị nhờ theme variables |
Vì vậy học Tailwind không phải học thuộc từng class. Học đúng là:
- hiểu CSS gốc;
- biết Tailwind đặt tên CSS đó như thế nào;
- biết utility nào lấy giá trị từ token;
- biết khi nào nên tạo token mới.
Một senior frontend không hỏi “class nào cho đẹp?”. Họ hỏi:
- Đây là layout một chiều hay hai chiều?
- Kích thước này là token thiết kế hay ngoại lệ?
- Màu này là primitive color hay semantic role?
- State này đến từ pseudo-class, ARIA,
data-state, hay app state? - Có đang phá accessibility hoặc responsive không?
Cheatsheet sẽ hữu ích nhất khi bạn dùng nó theo đúng chuỗi câu hỏi đó.
2. Bản đồ toàn bộ nhóm utility
Khi bị bí, đừng cuộn docs từ trên xuống. Hãy xác định loại vấn đề trước.
| Bạn đang làm gì? | Mở nhóm | Utility hay gặp |
|---|---|---|
| Đổi cách phần tử tham gia layout | Layout | block, flex, grid, hidden, contents |
| Đặt overlay, badge, sticky header | Layout | relative, absolute, fixed, sticky, inset-0, z-50 |
| Chặn tràn, tạo vùng scroll | Layout / Sizing | overflow-hidden, overflow-y-auto, min-h-0, h-dvh |
| Căn hàng ngang, toolbar, nav | Flexbox & Grid | flex, items-center, justify-between, gap-*, shrink-0, min-w-0 |
| Làm card grid, dashboard grid | Flexbox & Grid | grid, grid-cols-*, col-span-*, auto-rows-* |
| Tạo nhịp khoảng cách | Spacing | p-*, px-*, py-*, m-*, gap-*, space-y-* |
| Khóa kích thước icon/avatar | Sizing | size-*, w-*, h-*, shrink-0 |
| Chống text dài phá layout | Sizing / Typography | min-w-0, truncate, line-clamp-*, break-words |
| Xây hierarchy chữ | Typography | text-sm, font-medium, leading-relaxed, tracking-tight |
| Dùng màu token | Backgrounds / Typography / Borders | bg-background, text-foreground, border-border, ring-ring |
| Tạo focus/hover state | Variants + Borders | hover:*, focus-visible:ring-2, disabled:opacity-50 |
| Style SVG icon | SVG / Sizing | size-4, text-muted-foreground, stroke-current, fill-current |
| Tạo theme | Design Tokens | @theme, --color-*, --spacing-*, --radius-* |
Một cách nhớ ngắn:
- Layout quyết định phần tử nằm ở đâu.
- Spacing quyết định khoảng cách giữa các thứ.
- Sizing quyết định hộp lớn bao nhiêu.
- Typography quyết định chữ đọc thế nào.
- Color/Borders/Effects quyết định bề mặt và độ nổi.
- Variants quyết định khi nào style đổi.
- Tokens quyết định hệ thống có nhất quán hay không.
3. Layout: display, position, overflow, z-index
Layout là tầng đầu tiên. Nếu layout sai, mọi class màu sắc bên trên chỉ làm lỗi đẹp hơn.
Display: chọn cơ chế dòng chảy
| Utility | Dùng khi |
|---|---|
block | Phần tử chiếm hết dòng theo flow thường |
inline-block | Cần nằm trong dòng nhưng vẫn có width/height |
flex | Căn hàng/cột một chiều |
grid | Chia layout hai chiều |
hidden | Loại khỏi layout hoàn toàn |
contents | Bỏ wrapper box nhưng giữ children trong DOM |
Sai lầm phổ biến là dùng flex cho mọi thứ. Flex rất tốt cho hàng/cột một chiều, nhưng nếu bạn cần cả hàng lẫn cột, đặc biệt dashboard, form layout, card matrix, hãy nghĩ tới grid trước.
// Một chiều: toolbar
<div className="flex items-center justify-between gap-3">
<Logo />
<Nav />
<UserMenu />
</div>
// Hai chiều: dashboard cards
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</section>
Position: chỉ dùng khi thoát khỏi flow
position không phải công cụ căn layout chính. Nó là công cụ để thoát khỏi flow hoặc neo theo một hộp khác.
| Utility | Dùng cho |
|---|---|
relative | Tạo containing block cho absolute child |
absolute | Badge, icon overlay, menu nội bộ |
fixed | Modal backdrop, command palette, global toast |
sticky | Header bám trong vùng scroll |
inset-0 | Phủ đủ bốn cạnh |
Recipe overlay chuẩn:
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" />
<div className="fixed left-1/2 top-1/2 z-50 w-[min(92vw,32rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
<DialogContent />
</div>
Vì sao đúng:
fixed inset-0phủ viewport;z-50đưa layer lên trên app shell;bg-black/50chỉ làm mờ nền, không làm mờ content;w-[min(92vw,32rem)]là arbitrary value hợp lý vì đây là công thức layout riêng;- translate dùng để căn giữa theo chính kích thước của hộp.
Overflow: làm chủ vùng scroll
Trong app thật, lỗi scroll thường đến từ việc không xác định rõ ai được cuộn.
<div className="grid h-dvh grid-rows-[3.5rem_1fr] overflow-hidden">
<header className="border-b px-4">...</header>
<main className="min-h-0 overflow-y-auto p-4">...</main>
</div>
Hai class quan trọng:
overflow-hiddenở shell ngoài: không cho toàn page cuộn lung tung.min-h-0ở grid/flex child: cho phép child co thấp hơn nội dung đểoverflow-y-autohoạt động.
min-h-0 giống min-w-0 ở flex row: nhỏ nhưng cứu rất nhiều layout.
Z-index: ít tầng, đặt tên trong đầu
Không nên rải z-[9999]. Hãy có thang tư duy:
| Layer | Utility thường dùng |
|---|---|
| Nội dung thường | z-0 hoặc không đặt |
| Sticky header / sidebar | z-10, z-20 |
| Popover / dropdown | z-40 |
| Modal / command palette / toast | z-50 |
Nếu z-50 vẫn không nổi, lỗi thường không phải thiếu số. Có thể bạn đang ở trong stacking context mới tạo bởi transform, opacity, filter, isolation, hoặc position + z-index ở cha.
4. Flexbox: một chiều, nhưng phải hiểu shrink
Flexbox giải quyết layout một chiều: hàng ngang hoặc cột dọc. Hai trục luôn đổi theo flex-row/flex-col.
| Trục | flex-row | flex-col |
|---|---|---|
| Main axis | ngang | dọc |
| Cross axis | dọc | ngang |
justify-* | căn ngang | căn dọc |
items-* | căn dọc | căn ngang |
Toolbar chuẩn:
<header className="flex h-14 items-center gap-3 border-b px-4">
<a className="shrink-0 font-semibold" href="/">
Acme
</a>
<div className="min-w-0 flex-1">
<SearchInput className="w-full" />
</div>
<Button variant="ghost" size="icon" className="shrink-0">
<Settings className="size-4" />
</Button>
</header>
Ba chi tiết production:
- Logo/action dùng
shrink-0để không bị bóp. - Vùng search dùng
min-w-0 flex-1để được co. gap-3thuộc về parent, không rảimr-3từng child.
min-w-0: class nhỏ, giá trị lớn
Flex item mặc định có min-width: auto, nghĩa là nó không muốn nhỏ hơn nội dung. Vì vậy một filename dài có thể phá cả row.
<div className="flex items-center gap-3 rounded-md border p-3">
<FileIcon className="text-muted-foreground size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
quarterly-report-final-final-v12-really-long-name.pdf
</p>
<p className="text-muted-foreground truncate text-xs">
Updated by product-team@example.com
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0">
Open
</Button>
</div>
Nếu truncate không chạy trong flex row, kiểm tra min-w-0 trước khi nghi Tailwind.
flex-1, flex-auto, flex-none
| Utility | Nghĩa thực tế | Dùng khi |
|---|---|---|
flex-1 | chiếm phần còn lại, bỏ qua intrinsic size | search input, main content |
flex-auto | co giãn nhưng vẫn tôn trọng kích thước ban đầu | content có width tự nhiên |
flex-none | không grow, không shrink | badge/action cần cố định |
shrink-0 | không bị bóp nhỏ | icon, avatar, button |
Một layout tốt thường có đúng một vùng flex-1 và nhiều vùng shrink-0.
5. Grid: hai chiều và responsive layout
Grid nên là lựa chọn đầu tiên cho page layout, dashboard, card matrix và form layout phức tạp.
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard />
<StatCard />
<StatCard />
<StatCard />
</section>
Mobile-first nghĩa là class không prefix áp cho mobile trước. md:grid-cols-2 chỉ override khi viewport đủ rộng.
App shell bằng grid
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-dvh grid-cols-1 md:grid-cols-[16rem_1fr]">
<aside className="bg-muted/40 hidden border-r md:block">
<Sidebar />
</aside>
<div className="grid min-w-0 grid-rows-[3.5rem_1fr] overflow-hidden">
<header className="flex items-center gap-3 border-b px-4">
<MobileNav />
<SearchTrigger />
<UserMenu className="ml-auto" />
</header>
<main className="min-h-0 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
);
}
Tại sao đây là pattern tốt:
h-dvhxử lý viewport động trên mobile tốt hơnh-screen;md:grid-cols-[16rem_1fr]mô tả đúng kiến trúc sidebar/content;grid-rows-[3.5rem_1fr]khóa topbar và cho main lấy phần còn lại;min-w-0vàmin-h-0ngăn overflow ngầm;- sidebar mobile có thể chuyển thành
Sheetthay vì cố ép nó vào grid.
Form layout responsive
<form className="grid gap-4 md:grid-cols-2">
<Field name="firstName" label="First name" />
<Field name="lastName" label="Last name" />
<Field className="md:col-span-2" name="email" label="Email" />
<Field className="md:col-span-2" name="bio" label="Bio" />
</form>
col-span-* nên dùng cho layout role, không dùng để “chữa cháy” từng pixel.
6. Spacing và sizing: nhịp thiết kế, không phải đoán số
Tailwind spacing scale mặc định dựa trên 0.25rem. Với root font size 16px:
| Class | Giá trị |
|---|---|
p-1 | 4px |
p-2 | 8px |
p-3 | 12px |
p-4 | 16px |
p-6 | 24px |
p-8 | 32px |
Đừng chọn số theo cảm giác từng chỗ. Hãy tạo nhịp:
- component compact:
px-2.5 py-1.5,gap-2; - form control:
h-9hoặch-10,px-3; - card:
p-4hoặcp-6; - page section:
py-10,py-12,py-16; - dashboard grid:
gap-4, page paddingp-4 md:p-6.
size-* cho icon/avatar/button vuông
<Button size="icon" className="size-9">
<Search className="size-4" />
</Button>
<Avatar className="size-10" />
size-* tốt hơn viết h-* w-* lặp lại khi phần tử cần vuông.
w-full, max-w-*, w-fit
| Utility | Dùng khi |
|---|---|
w-full | input/card chiếm hết parent |
max-w-prose | cột bài viết dễ đọc |
max-w-screen-xl | container page lớn |
w-fit | badge/button vừa nội dung |
w-[min(92vw,32rem)] | công thức modal responsive |
Arbitrary value không xấu. Nó xấu khi thay thế token thiết kế. Nếu giá trị là công thức layout riêng và xuất hiện một lần, arbitrary value rất hợp lý.
7. Typography: làm chữ dễ đọc trước khi làm chữ đẹp
Typography trong UI app khác typography landing page. Dashboard, form, table cần rõ, scan nhanh, ít kịch tính.
Một thang chữ thực dụng:
| Vai trò | Utility |
|---|---|
| Page title | text-2xl font-semibold tracking-tight |
| Section title | text-lg font-semibold |
| Card title | text-sm font-medium |
| Body | text-sm leading-6 |
| Helper text | text-xs text-muted-foreground |
| Table cell | text-sm |
| Badge | text-xs font-medium |
Ví dụ card header:
<header className="space-y-1.5">
<h2 className="text-lg font-semibold tracking-tight">Billing</h2>
<p className="text-muted-foreground text-sm">
Manage invoices, payment method, and tax information.
</p>
</header>
truncate, line-clamp, text-balance, text-pretty
| Utility | Dùng khi |
|---|---|
truncate | một dòng, cắt bằng dấu ba chấm |
line-clamp-2 | card title/description tối đa n dòng |
break-words | chuỗi dài như URL/email/token cần xuống dòng |
text-balance | headline cân dòng đẹp hơn |
text-pretty | paragraph giảm orphan word |
Đừng dùng truncate cho nội dung người dùng bắt buộc đọc đủ, ví dụ lỗi form quan trọng. Khi cắt text, nên có tooltip/title hoặc màn detail để xem đầy đủ.
8. Màu sắc: primitive color vs semantic token
Trong prototype, bg-blue-600 text-white ổn. Trong design system, component nên dùng semantic token:
// Prototype nhanh
<button className="rounded-md bg-blue-600 px-3 py-2 text-white">
Save
</button>
// Component system
<button className="rounded-md bg-primary px-3 py-2 text-primary-foreground">
Save
</button>
Sự khác biệt:
| Cách viết | Ý nghĩa |
|---|---|
bg-blue-600 | màu cụ thể |
bg-primary | vai trò trong hệ thống |
text-muted-foreground | chữ phụ, helper text, placeholder |
border-border | đường chia mặc định |
ring-ring | focus ring mặc định |
shadcn dùng token semantic vì một component không nên biết brand hiện tại là xanh, tím hay cam. Component chỉ biết vai trò: primary, muted, destructive, card, popover.
Quy tắc background/foreground
Mỗi surface có foreground tương ứng:
| Surface | Text/icon nên đi cùng |
|---|---|
bg-background | text-foreground |
bg-card | text-card-foreground |
bg-popover | text-popover-foreground |
bg-primary | text-primary-foreground |
bg-secondary | text-secondary-foreground |
bg-muted | thường text-muted-foreground |
bg-accent | text-accent-foreground |
bg-destructive | thường text-white hoặc token destructive foreground nếu theme có |
Khi text khó đọc, đừng sửa bằng text-white tùy tiện. Hãy kiểm tra cặp token surface/foreground.
/opacity khác opacity-*
<div className="bg-black/50">Overlay</div>
<div className="opacity-50">Whole element is faded</div>
bg-black/50 chỉ làm trong suốt màu nền. Text/icon bên trong vẫn rõ. opacity-50 làm mờ cả element và children. Với overlay, dùng màu có alpha. Với disabled state, dùng disabled:opacity-50.
9. Borders, rings, shadows: interactive surface
Một input production thường cần đủ bốn tầng: border, background, text, focus ring.
<input
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm transition-colors outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="you@example.com"
/>
Giải phẫu:
border border-input: đường viền theo theme form control;bg-background text-foreground: surface và chữ theo token;shadow-sm: nổi rất nhẹ;outline-none focus-visible:ring-2 focus-visible:ring-ring: tự vẽ focus ring thay outline mặc định;disabled:*: trạng thái bất hoạt rõ ràng.
ring vs outline
| Công cụ | Đặc điểm |
|---|---|
outline-* | CSS outline thật, nằm ngoài border, tốt cho accessibility |
ring-* | box-shadow mô phỏng vòng, linh hoạt trong design system |
shadcn thường dùng ring-ring/outline-ring/50 để focus state đồng bộ theme. Dù dùng cách nào, focus keyboard phải nhìn thấy rõ. Đừng xóa outline nếu chưa thay bằng focus indicator khác.
10. Variants: điều kiện áp dụng utility
Variant là tiền tố điều kiện:
<button className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50">
Save
</button>
Đọc là:
- bình thường:
bg-primary text-primary-foreground; - khi hover:
bg-primary/90; - khi focus bằng keyboard: có ring;
- khi disabled: không nhận pointer event và mờ đi.
Một variant áp cho đúng một utility. Muốn hover đổi ba thứ, lặp prefix ba lần:
<button className="hover:bg-accent hover:text-accent-foreground hover:shadow-sm">
Hover me
</button>
data-* là cầu nối với Radix
Radix phơi state qua data attribute như data-state="open" hoặc data-disabled. Tailwind style trực tiếp được:
<DropdownMenu.Item className="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
Rename
</DropdownMenu.Item>
Vì vậy học data-[...] không phải “syntax lạ”. Nó là cách style component headless mà không cần state class tự chế.
group, peer, has
| Variant | Khi nào dùng |
|---|---|
group-hover:* | child đổi theo state của parent |
peer-checked:* | sibling đổi theo state của input/sibling trước nó |
has-* | parent đổi theo descendant |
Card hover:
<a className="group hover:bg-accent block rounded-lg border p-4 transition-colors">
<h3 className="group-hover:text-accent-foreground font-medium">
Project Aurora
</h3>
<p className="text-muted-foreground group-hover:text-accent-foreground/80 text-sm">
Open dashboard
</p>
</a>
Form row dùng has:
<label className="has-[:checked]:border-primary has-[:checked]:bg-primary/5 flex items-start gap-3 rounded-md border p-3">
<input type="checkbox" className="mt-1" />
<span>
<span className="block text-sm font-medium">Enable alerts</span>
<span className="text-muted-foreground block text-sm">
Send notifications when usage exceeds the limit.
</span>
</span>
</label>
11. SVG và icon: để icon kế thừa màu
Icon trong app nên đi theo text color, không hard-code màu trong SVG.
<Button variant="ghost" size="icon">
<Search className="text-muted-foreground size-4" />
</Button>
Với icon library như Lucide, size-4 đặt width/height, text-muted-foreground điều khiển stroke vì icon dùng currentColor.
| Tình huống | Class |
|---|---|
| Icon trong button | size-4 |
| Icon lớn hơn trong empty state | size-8 text-muted-foreground |
| Icon không bị co trong row | size-4 shrink-0 |
| SVG tự viết cần fill theo text | fill-current |
| SVG tự viết cần stroke theo text | stroke-current |
Không dùng icon chỉ để trang trí nếu nó làm rối screen reader. Với icon decorative trong button có text, thường đặt aria-hidden="true".
12. @theme: khi utility trở thành design system
Trong Tailwind v4, @theme định nghĩa theme variables. Chúng không chỉ là CSS variables; chúng còn quyết định utility nào tồn tại.
@import 'tailwindcss';
@theme {
--color-brand: oklch(0.62 0.18 255);
--radius-card: 0.75rem;
--spacing-page: 1.5rem;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.06);
}
Từ đó bạn có:
<article className="rounded-card bg-brand p-page shadow-card">...</article>
Namespace quyết định utility sinh ra:
| Namespace | Utility sinh ra |
|---|---|
--color-* | bg-*, text-*, border-*, ring-*, fill-*, from-* |
--font-* | font-* font-family |
--text-* | text-* font-size |
--font-weight-* | font-* font-weight |
--tracking-* | tracking-* |
--leading-* | leading-* |
--spacing-* | p-*, m-*, gap-*, w-*, h-*, inset-*, size-* |
--radius-* | rounded-* |
--shadow-* | shadow-* |
--blur-* | blur-* |
--breakpoint-* | responsive variants như tablet:* |
--container-* | container query variants và container sizes |
--aspect-* | aspect-* |
--ease-* | ease-* |
--animate-* | animate-* |
Quy tắc:
- Muốn
bg-brand, tạo--color-brand. - Muốn
rounded-card, tạo--radius-card. - Muốn
p-page, tạo--spacing-page. - Muốn
tablet:grid, tạo--breakpoint-tablet.
Token sai namespace là lỗi rất hay gặp:
/* Sai: không sinh rounded-card */
@theme {
--rounded-card: 0.75rem;
}
/* Đúng */
@theme {
--radius-card: 0.75rem;
}
@theme vs :root
| Dùng | Khi |
|---|---|
@theme | Bạn muốn Tailwind sinh utility từ token |
:root | Bạn chỉ cần CSS variable runtime, không cần utility |
@theme {
--color-brand: oklch(0.62 0.18 255); /* sinh bg-brand */
}
:root {
--app-sidebar-width: 16rem; /* biến runtime nội bộ */
}
Đừng đưa mọi biến vào @theme. Chỉ token thiết kế cần utility mới nên vào đó.
@theme inline trong shadcn
shadcn thường có token semantic ở :root/.dark, rồi nối sang Tailwind bằng @theme inline.
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
Component dùng utility, không biết light/dark cụ thể:
<main className="bg-background text-foreground">
<Button className="bg-primary text-primary-foreground">Upgrade</Button>
</main>
Đây là điểm trưởng thành của hệ thống: component dùng vai trò semantic, theme quyết định giá trị thật.
13. Recipe production: từ class rời thành component
Button primitive
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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-white hover:bg-destructive/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
Một component như vậy gom nhiều phần đã học:
- layout:
inline-flex items-center justify-center gap-2; - typography:
text-sm font-medium; - state:
focus-visible:*,disabled:*,hover:*; - tokens:
bg-primary,text-primary-foreground,border-input; - icon contract:
[&_svg]:size-4; - extensibility:
asChild,cva,cn.
Card dashboard
<article className="bg-card text-card-foreground rounded-lg border p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-muted-foreground truncate text-sm">Monthly revenue</p>
<p className="mt-2 text-2xl font-semibold tracking-tight">$48,230</p>
</div>
<Badge variant="secondary" className="shrink-0">
+12.4%
</Badge>
</div>
</article>
Điểm production:
- card dùng
bg-card text-card-foreground; - content dài có
min-w-0; - badge/action không bị co nhờ
shrink-0; - không dùng shadow quá mạnh trong dashboard.
Empty state
<div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<Inbox className="text-muted-foreground size-10" />
<h3 className="mt-4 text-base font-semibold">No invoices yet</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Create your first invoice or import existing billing data from CSV.
</p>
<Button className="mt-4">Create invoice</Button>
</div>
Empty state cần có:
- icon hoặc visual nhỏ;
- heading rõ;
- mô tả nêu trạng thái và hướng đi;
- action chính nếu người dùng có thể tự xử lý.
Data table shell
<div className="overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr className="border-b">
<th className="h-10 px-3 text-left font-medium">Customer</th>
<th className="h-10 px-3 text-left font-medium">Status</th>
<th className="h-10 px-3 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr className="hover:bg-muted/50 border-b last:border-0">
<td className="max-w-[18rem] px-3 py-2">
<p className="truncate font-medium">{row.customer}</p>
<p className="text-muted-foreground truncate text-xs">
{row.email}
</p>
</td>
<td className="px-3 py-2">
<Badge variant="outline">{row.status}</Badge>
</td>
<td className="px-3 py-2 text-right tabular-nums">{row.amount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
Table tốt cần:
- wrapper
overflow-x-autocho mobile; tabular-numscho số thẳng cột;text-rightcho tiền/số lượng;max-w-* truncatecho cell có text dài;- hover state nhẹ, không làm loạn màu.
14. Debug checklist: khi Tailwind “không ăn”
1. Class có được generate không?
Nếu bạn ghép class động, Tailwind scanner không thấy:
// Sai
const className = `bg-${color}-500`;
// Đúng
const colorClass = {
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
}[tone];
Nếu DevTools không thấy rule cho class đó, vấn đề là build/scanner, không phải specificity.
2. Class có conflict không?
<div className="p-2 p-4" />
Hai class cùng nhóm padding. Class sau thắng theo thứ tự CSS generated/merge. Trong component React, dùng tailwind-merge qua cn() để caller override an toàn:
className={cn("rounded-md px-3 py-2", className)}
3. truncate không chạy?
Checklist:
- parent có width giới hạn chưa?
- flex item chứa text có
min-w-0chưa? - text element có
overflow-hidden whitespace-nowrap text-ellipsischưa?truncategói ba cái này.
4. overflow-y-auto không cuộn?
Checklist:
- parent có chiều cao rõ chưa (
h-dvh,h-full, grid row)? - child trong grid/flex có
min-h-0chưa? - có parent nào đang
overflow-visiblelàm page cuộn thay vì region không?
5. Focus ring không thấy?
Checklist:
- element focusable không?
- có
outline-nonenhưng quênfocus-visible:ring-*không? - ring màu có đủ contrast không?
- parent có
overflow-hiddencắt mất ring không?
6. Dark mode lệch?
Checklist:
- app có class
.darktrênhtmlhoặc root đúng chưa? - token được override cùng tên trong
.darkchưa? - component có dùng semantic token hay hard-code
bg-white text-black? @theme inlinecó trỏ token đúng không?
7. Modal bị nằm dưới header?
Checklist:
- modal render trong portal lên cuối body chưa?
- backdrop/content có
fixedchưa? z-indexcó bị stacking context của parent chặn không?- parent có
transform/opacity/filtertạo stacking context không?
15. Quy tắc chọn utility, token, arbitrary value
| Tình huống | Chọn |
|---|---|
| Giá trị thuộc hệ thống thiết kế và dùng nhiều nơi | @theme token |
| Giá trị là vai trò UI như nền app, chữ phụ, action chính | semantic token (bg-background, text-muted-foreground) |
| Giá trị là layout formula một lần | arbitrary value (w-[min(92vw,32rem)]) |
| Giá trị là spacing/radius/font scale mặc định | utility mặc định (p-4, rounded-md, text-sm) |
| Cụm class lặp lại với hành vi | component |
| Cụm class có nhiều variant/size | cva |
| State đến từ Radix | data-[state=...] hoặc shadcn component có sẵn |
Một dấu hiệu bạn đang đi đúng hướng: component càng ngày càng dùng nhiều token semantic và ít màu hard-code.
16. Bài tập: từ zero đến có kinh nghiệm
Bài 1 — Toolbar không vỡ layout
Yêu cầu:
- logo trái;
- search input giữa;
- user menu phải;
- search text dài không làm vỡ layout;
- mobile vẫn ổn.
Gợi ý class: flex, items-center, gap-3, shrink-0, min-w-0, flex-1, w-full.
Bài 2 — Dashboard grid
Yêu cầu:
- mobile 1 cột;
- tablet 2 cột;
- desktop 4 cột;
- card dùng token shadcn;
- số dùng
tabular-nums.
Gợi ý class: grid, gap-4, sm:grid-cols-2, xl:grid-cols-4, bg-card, text-card-foreground, text-muted-foreground.
Bài 3 — Modal shell
Yêu cầu:
- backdrop phủ màn;
- content căn giữa;
- width responsive;
- focus ring cho action;
- không dùng
opacity-50trên wrapper content.
Gợi ý class: fixed, inset-0, z-50, bg-black/50, w-[min(92vw,32rem)], -translate-x-1/2, focus-visible:ring-2.
Bài 4 — Token hóa theme
Yêu cầu:
- tạo
--color-brand; - tạo
--radius-card; - tạo
--spacing-page; - dùng
bg-brand,rounded-card,p-page; - sau đó chuyển component sang semantic token nếu nó là component dùng chung.
Bài 5 — Debug thực chiến
Tự tạo ba lỗi rồi sửa:
truncatekhông chạy trong flex row.bg-${color}-500không generate CSS.- Dark mode đổi nền nhưng chữ vẫn sai contrast.
Khi bạn tự sửa được ba lỗi này bằng DevTools, bạn đã vượt khỏi mức “biết class” và bắt đầu dùng Tailwind như một engineer.
Điểm chính
- Tailwind là API ngắn và nhất quán cho CSS, không phải phép màu thay CSS.
- Hãy phân loại vấn đề trước: layout, spacing, sizing, typography, state, token.
- Flex giải quyết một chiều; grid giải quyết hai chiều.
min-w-0vàmin-h-0là hai class nhỏ cứu rất nhiều layout flex/grid.- shadcn component nên dùng semantic token:
bg-background,text-foreground,border-border,ring-ring. @themetạo utility;:rootchỉ tạo CSS variable runtime.- Arbitrary value hợp lý khi nó là công thức layout riêng, không phải token thiết kế bị bỏ quên.
- Debug Tailwind bằng DevTools: xem class có rule không, rule có bị conflict không, token có tồn tại không.
Kết series
Nếu đi từ Phần 1 tới đây, bạn đã có đủ ba tầng:
- Tailwind để mô tả visual system bằng utility và token.
- Radix để lấy hành vi headless, accessible, composable.
- shadcn/ui để sở hữu component production, theme được, sửa được, mở rộng được.
Đích cuối không phải “thuộc nhiều class”. Đích cuối là nhìn một UI phức tạp và tự tin tách nó thành layout, state, token, component, accessibility, performance. Khi làm được điều đó, Tailwind không còn là cheatsheet nữa. Nó trở thành ngôn ngữ thiết kế bạn dùng hằng ngày.