Tailwind, Radix & shadcn/ui · Part 12 — Pro Patterns & Dashboard Capstone
Tie it together: a cva-based variant system, a sortable/filterable data table with TanStack Table, a command palette with cmdk, toasts with sonner, and the composition patterns of a real dashboard. With a live capstone demo.
Bạn đã học mọi tầng: utility và token Tailwind (Phần 1–3), variant và responsive (Phần 4), cva/cn để dựng component (Phần 5), hành vi headless của Radix (Phần 7–8), mô hình “code bạn sở hữu” của shadcn (Phần 9), theme và dark mode (Phần 10), và forms với RHF/Zod (Phần 11). Phần này là capstone chính: ráp tất cả lại thành các mẫu mà bạn thật sự ship trong một dashboard production — app-shell responsive, data table, dialog/sheet, toast, và các trạng thái loading/empty. Sau capstone, series còn hai bài reference để bạn tra cứu lâu dài: variants deep reference và Tailwind v4 cheatsheet.
Mục tiêu của bài này không phải “thêm component mới” mà là đặt mọi thứ cạnh nhau và chỉ ra chúng khớp như thế nào — vì đó chính là khoảng cách giữa “biết từng mảnh” và “dựng được app thật”.
Bấm ⌘K/Ctrl K, sắp xếp cột, lọc, và bật toast trong capstone:
Cách đọc capstone như một kiến trúc sư frontend
Đừng nhìn dashboard như một màn hình lớn. Hãy tách nó thành năm luồng trách nhiệm:
| Luồng | Câu hỏi kiến trúc |
|---|---|
| Layout | Vùng nào đứng yên, vùng nào cuộn, mobile chuyển hình ra sao? |
| Data | Trạng thái sort/filter/pagination nằm ở đâu, URL có cần đồng bộ không? |
| Interaction | Dialog/sheet/menu có đúng focus, Esc, click outside, keyboard không? |
| Feedback | Loading/empty/error/success có được thiết kế như state thật không? |
| System | Component có dùng token/variant chung hay mỗi màn tự phát minh style? |
Capstone tốt không phải vì nó có nhiều component. Nó tốt khi mỗi component có trách nhiệm rõ, state đặt đúng chỗ, và các pattern lặp lại được đóng gói để màn tiếp theo dựng nhanh hơn màn trước.
1. App-shell — bộ khung của mọi dashboard
Gần như mọi app nội bộ đều cùng một hình: sidebar điều hướng bên trái, topbar trên cùng, và vùng nội dung cuộn được ở giữa. Sai lầm phổ biến của người mới là dựng nó bằng position: fixed rải rác và margin-left đoán mò. Cách đúng: dùng CSS Grid hai chiều đặt tên vùng — Grid sinh ra để làm chính việc này.
Ý tưởng cốt lõi là một grid [sidebar | content] theo cột, trong đó cột content lại là một grid [topbar / main] theo hàng. Mỗi vùng tự lo cuộn của mình, nên topbar và sidebar đứng yên trong khi main cuộn.
┌───────────────────────────────────────────┐
│ grid: cols = [16rem | 1fr] │
│ ┌─────────┬───────────────────────────────┐│
│ │ │ topbar (h-14, sticky/đứng) ││
│ │ sidebar ├───────────────────────────────┤│
│ │ (w-64) │ ││
│ │ đứng │ main (overflow-y-auto) ││
│ │ yên │ ← chỉ vùng này cuộn ││
│ │ │ ││
│ └─────────┴───────────────────────────────┘│
└───────────────────────────────────────────┘
// AppShell.tsx — khung dùng lại cho mọi trang
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-dvh grid-cols-1 md:grid-cols-[16rem_1fr]">
{/* Sidebar: ẩn trên mobile, hiện từ md trở lên */}
<aside className="bg-muted/40 hidden border-r md:block">
<Sidebar />
</aside>
{/* Cột phải: topbar cố định + main cuộn riêng */}
<div className="grid grid-rows-[3.5rem_1fr] overflow-hidden">
<header className="flex items-center gap-3 border-b px-4">
<MobileNav /> {/* nút hamburger mở Sheet trên mobile */}
<SearchTrigger /> {/* mở ⌘K */}
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
<UserMenu />
</div>
</header>
<main className="overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
);
}
Vì sao mẫu này thắng:
h-dvh(dynamic viewport height) thay choh-screen— trên mobile, thanh địa chỉ trình duyệt co/giãn;dvhtính đúng chiều cao thực, không bị “hụt đáy”.grid-cols-[16rem_1fr]: sidebar cố định16rem, content nuốt phần còn lại1fr. Khôngmargin-leftthủ công, không tính toán nào lệch khi đổi độ rộng sidebar — chỉ sửa một con số.overflow-hiddenở cột phải +overflow-y-autoởmain: chỉ main cuộn, topbar và sidebar đứng yên một cách tự nhiên mà không cầnposition: sticky.hidden md:blocktrên sidebar: dướimdsidebar biến mất và ta chuyển sangSheet(mục 6) — đây là pattern app-shell responsive chuẩn.
Chốt: app-shell là bài toán layout, không phải bài toán positioning. Hễ bạn thấy mình viết
fixed+margin-leftđể né nhau, gần như chắc chắn một grid hai chiều sẽ gọn và đúng hơn.
2. Compound components — phơi các phần, không nhồi prop
Khi một component bắt đầu mọc prop như title, subtitle, footer, headerAction, showDivider… nó đang nói với bạn: “tôi nên là một bộ phần ghép, không phải một hộp nhiều nút vặn”. Đây là compound component — đúng pattern Radix và shadcn dùng ở mọi nơi (Card, Dialog, Select…).
// ❌ "god component" — mỗi nhu cầu mới = thêm một prop
<StatCard
title="MRR" value="$48.2k" delta="+12.4%"
deltaColor="green" icon={<DollarIcon />} footer="vs last month"
showChart chartData={...}
/>
// ✅ compound — caller tự ghép đúng cái họ cần
<Card>
<CardHeader>
<CardDescription>MRR</CardDescription>
<CardTitle className="text-3xl">$48.2k</CardTitle>
</CardHeader>
<CardContent>
<Trend value={12.4} /> vs tháng trước
</CardContent>
</Card>
Bản chất kỹ thuật: compound = nhiều component nhỏ chia sẻ một context, dùng cạnh nhau. Bạn tự dựng cũng đơn giản:
const TabsCtx = React.createContext<{
value: string;
setValue: (v: string) => void;
} | null>(null);
function Tabs({
defaultValue,
children,
}: {
defaultValue: string;
children: React.ReactNode;
}) {
const [value, setValue] = React.useState(defaultValue);
return (
<TabsCtx.Provider value={{ value, setValue }}>{children}</TabsCtx.Provider>
);
}
function TabTrigger({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) {
const ctx = React.useContext(TabsCtx)!; // đọc state chung
const active = ctx.value === value;
return (
<button
onClick={() => ctx.setValue(value)}
className={cn(
'px-3 py-1.5 text-sm',
active && 'border-primary border-b-2 font-medium'
)}
>
{children}
</button>
);
}
| Nhồi prop | Compound component |
|---|---|
| Mỗi biến thể mới = thêm một prop | Caller ghép phần có sẵn, không sửa component |
| Khó sắp xếp lại thứ tự nội dung | Thứ tự = thứ tự JSX, tự nhiên |
| API phình theo thời gian | API ổn định: chỉ là các “mảnh ghép” |
| Khó chèn nội dung tùy ý vào giữa | Chèn bất cứ gì giữa các phần |
Khi nào dùng cái nào: nếu một component có dưới 3 nút vặn cố định, props là đủ. Khi nó bắt đầu cần “cho phép chèn nội dung tùy ý” hoặc “sắp xếp lại các phần”, chuyển sang compound.
3. asChild & slot — cửa thoát đa hình
asChild là pattern bạn gặp khắp Radix/shadcn và nó giải một bài toán React kinh điển: “tôi muốn style và hành vi của Button, nhưng tôi cần render một thẻ <a> (hoặc <Link> của router)”. Không có asChild, bạn kẹt với việc lồng <a><button>…</button></a> (HTML không hợp lệ) hoặc nhân đôi style.
// Mặc định: Button render <button>
<Button>Lưu</Button>
// asChild: Button "nhường" thẻ của nó cho con — hợp nhất props/class vào <a>
<Button asChild>
<a href="/billing">Tới trang thanh toán</a>
</Button>
// Cực hữu ích với router — giữ nguyên style nút, nhưng là điều hướng client
<Button asChild variant="outline">
<Link to="/customers/new">+ Khách mới</Link>
</Button>
Under the hood, asChild được hiện thực bằng Slot của Radix. Slot không render thẻ riêng — nó lấy props/className của mình và merge vào phần tử con duy nhất:
import { Slot } from '@radix-ui/react-slot';
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild, className, variant, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'; // ← chọn thẻ ở runtime
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
);
}
);
| Tình huống | Không có asChild | Có asChild |
|---|---|---|
| Nút là link nội bộ | Lồng thẻ sai / copy style | <Button asChild><Link/></Button> |
| Trigger Dialog là thẻ tùy ý | Bọc thêm wrapper | <DialogTrigger asChild> |
| Tooltip quanh icon-button | DOM thừa | merge thẳng vào con |
Gotcha
asChild: phần tử con phải là một node duy nhất và phải chuyển tiếp...props+refxuống DOM (vì sao đó component custom của bạn dùng làm con phảiforwardRef). Hai con, hoặc con “nuốt” props, sẽ làm Slot mất hành vi.
4. Hệ variant scale được với cva
Một design system là sự nhất quán: mọi component tương tác nên chung một hình dạng API. cva (class-variance-authority, Phần 5) tập trung mọi biến thể vào một chỗ có type, để toàn app nói cùng một thứ tiếng variant / size.
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
// 1 nguồn sự thật cho mọi badge trong app
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
success: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
warning: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
destructive: 'bg-destructive/15 text-destructive',
},
},
defaultVariants: { variant: 'default' },
}
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
Hai dòng quan trọng nhất là cuối: cva(...) sinh chuỗi class theo variant, còn cn(..., className) cho caller override an toàn (vì cn = clsx + tailwind-merge, nên className truyền vào thắng class mặc định mà không “đánh nhau”). Mọi component trong app — Button, Badge, Alert, Input — đều theo đúng khuôn này:
cva(base, { variants, defaultVariants }) → chọn class theo prop
↓
cn(variantClasses, className) → caller override an toàn
↓
asChild ? Slot : 'tag' → đa hình khi cần
Khi ba mảnh —
cva(variant),cn(merge),Slot(đa hình) — xuất hiện cùng nhau trong một component, bạn đang nhìn vào “DNA” của shadcn. Nhận ra nó một lần là đọc được mọi component shadcn còn lại.
5. Data table — logic headless + style của bạn
Data table là nơi mọi thứ hội tụ. shadcn không cho bạn một <DataTable> ôm trọn; nó cho các primitive Table đã style + bạn cắm TanStack Table (logic headless) vào — đúng triết lý “logic tách khỏi trình bày” mà Radix đã dạy, nay áp cho bảng.
npm install @tanstack/react-table
npx shadcn@latest add table dropdown-menu
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table';
type Customer = {
id: string;
name: string;
plan: string;
mrr: number;
status: 'active' | 'past_due' | 'churned';
};
const columns: ColumnDef<Customer>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<button
className="flex items-center gap-1"
onClick={column.getToggleSortingHandler()}
>
Khách hàng <ArrowUpDown className="size-3.5 opacity-60" />
</button>
),
},
{ accessorKey: 'plan', header: 'Gói' },
{
accessorKey: 'mrr',
header: 'MRR',
cell: ({ row }) => `$${row.getValue<number>('mrr').toLocaleString()}`,
},
{
accessorKey: 'status',
header: 'Trạng thái',
cell: ({ row }) => (
<Badge variant={statusVariant(row.getValue('status'))}>
{row.getValue('status')}
</Badge>
),
},
{
id: 'actions',
cell: ({ row }) => <RowActions customer={row.original} />, // menu ... mỗi hàng (mục 5b)
},
];
Component bảng — chú ý sticky header và bọc cuộn ngang cho responsive:
function CustomersTable({ data }: { data: Customer[] }) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [filter, setFilter] = React.useState('');
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter: filter },
onSortingChange: setSorting,
onGlobalFilterChange: setFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
// bọc cuộn ngang: trên màn hẹp bảng cuộn ngang thay vì vỡ layout
<div className="relative max-h-[28rem] overflow-auto rounded-md border">
<Table>
{/* sticky header: tiêu đề cột dính khi cuộn dọc */}
<TableHeader className="bg-background sticky top-0 z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
// empty state ngay trong bảng (mục 8)
<TableRow>
<TableCell
colSpan={columns.length}
className="text-muted-foreground h-24 text-center"
>
Không có khách hàng nào khớp bộ lọc.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
5b. Row actions qua DropdownMenu
Menu ”…” cuối mỗi hàng là một DropdownMenu Radix — đã có sẵn bẫy focus, điều hướng bàn phím, đóng khi Escape:
function RowActions({ customer }: { customer: Customer }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<span className="sr-only">Mở menu</span>{' '}
{/* nhãn cho screen reader */}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(customer.id)}
>
Sao chép ID
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editCustomer(customer)}>
Sửa
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => removeCustomer(customer)}
>
Xoá
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
| Tính năng bảng | Ai lo | Bạn viết gì |
|---|---|---|
| Sắp xếp / lọc / phân trang | TanStack (getSortedRowModel…) | bật model + nối state |
| Sticky header | Tailwind | sticky top-0 z-10 bg-background trên TableHeader |
| Responsive | Tailwind | bọc overflow-auto quanh Table |
| Row actions | Radix DropdownMenu | một menu, asChild trên trigger |
| Empty state | bạn | hàng colSpan khi rows.length === 0 |
Vì sao headless thắng: TanStack không biết gì về Tailwind hay shadcn; nó chỉ trả về model (hàng nào, sắp ra sao). Bạn toàn quyền với markup — nên bảng của bạn theme, responsive, và a11y giống hệt phần còn lại của app, không phải một ốc đảo style riêng.
6. Dialog & Sheet — cùng một luồng, hai hình
Dialog (modal giữa màn hình) và Sheet (panel trượt từ cạnh) là cùng một primitive Radix với cách trình bày khác. Cả hai cho bạn miễn phí: overlay, bẫy focus, khôi phục focus khi đóng, Escape để đóng, khóa cuộn nền, và ARIA đúng. Bạn chỉ lo nội dung.
// Dialog — xác nhận hành động phá huỷ
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Xoá tài khoản</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Bạn chắc chứ?</DialogTitle>
<DialogDescription>Hành động này không thể hoàn tác.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Huỷ</Button>
</DialogClose>
<Button variant="destructive" onClick={confirmDelete}>
Xoá vĩnh viễn
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Sheet đúng API đó, chỉ thêm side để chọn cạnh trượt — lý tưởng cho mobile nav trong app-shell ở mục 1:
// MobileNav — hamburger mở sidebar dạng trượt trên màn hẹp
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
{' '}
{/* chỉ hiện dưới md */}
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0">
<Sidebar /> {/* tái dùng đúng sidebar của desktop */}
</SheetContent>
</Sheet>
| Dùng | Chọn | Vì sao |
|---|---|---|
| Xác nhận, form ngắn | Dialog | tiêu điểm vào giữa, ngắn gọn |
| Nav mobile, panel filter, chi tiết dài | Sheet | trượt từ cạnh, không che hết |
| Cảnh báo phá huỷ (xoá…) | AlertDialog | bắt buộc chọn, không đóng bằng click nền |
Gotcha a11y: luôn có
DialogTitle(và nên cóDialogDescription). Radix dùng chúng choaria-labelledby/aria-describedby; thiếuDialogTitle, screen reader đọc một hộp vô danh — và shadcn sẽ cảnh báo trong console. Nếu vì lý do thiết kế phải ẩn tiêu đề, bọc nó trongVisuallyHiddenchứ đừng bỏ.
7. Toast & notification — sonner
shadcn dùng sonner cho toast: gọi mệnh lệnh, không context lằng nhằng, không boilerplate. Render <Toaster /> một lần ở gốc, rồi toast() từ bất cứ đâu — kể cả ngoài component React (trong file lib/).
npx shadcn@latest add sonner
// app root — render đúng một lần
import { Toaster } from '@/components/ui/sonner';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<Toaster richColors closeButton position="top-right" />
</>
);
}
import { toast } from 'sonner';
toast.success('Đã thêm khách hàng');
toast.error('Có lỗi xảy ra');
// gắn vào một Promise — sonner tự đổi loading → success/error
toast.promise(saveCustomer(payload), {
loading: 'Đang lưu…',
success: 'Đã lưu',
error: (err) => `Thất bại: ${err.message}`,
});
// toast có hành động (undo) — pattern rất "pro"
toast('Đã xoá khách hàng', {
action: { label: 'Hoàn tác', onClick: () => restoreCustomer() },
});
| Loại phản hồi | Dùng |
|---|---|
| Kết quả thoáng qua (đã lưu, đã copy) | toast.success / toast.error |
| Tác vụ async chờ | toast.promise |
| Hành động đảo ngược được | toast(..., { action }) (Undo) |
| Lỗi nghiêm trọng cần đọc kỹ | không dùng toast — dùng Alert inline hoặc Dialog |
Khi nào không dùng toast: toast biến mất sau vài giây và dễ bị bỏ lỡ. Lỗi validation form, thông tin người dùng phải đọc, hay trạng thái bền vững → dùng
Alertinline hoặc Dialog. Toast chỉ cho phản hồi thoáng qua, không bắt buộc.
8. Loading, skeleton & empty states
App nghiệp dư chỉ có hai trạng thái: “trống” và “đầy đủ”. App pro thiết kế cho bốn: loading, có dữ liệu, rỗng (empty), và lỗi. Bỏ qua ba cái sau là dấu hiệu rõ nhất của UI chưa chín.
Skeleton — giữ layout ổn định khi đang tải, tránh “nhảy” khi data về. Skeleton nên trùng hình với nội dung thật:
function StatCardSkeleton() {
return (
<Card>
<CardHeader className="gap-2">
<Skeleton className="h-4 w-20" /> {/* nhãn */}
<Skeleton className="h-8 w-28" /> {/* số lớn */}
</CardHeader>
</Card>
);
}
// dùng: chọn UI theo trạng thái fetch
{
isLoading ? (
<div className="grid gap-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<StatCardSkeleton key={i} />
))}
</div>
) : (
<Stats data={data} />
);
}
Empty state — không bao giờ để một vùng trống trơn; nói cho người dùng vì sao trống và làm gì tiếp:
function EmptyCustomers() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-12 text-center">
<Users className="text-muted-foreground size-10" />
<div>
<p className="font-medium">Chưa có khách hàng nào</p>
<p className="text-muted-foreground text-sm">
Thêm khách đầu tiên để bắt đầu theo dõi doanh thu.
</p>
</div>
<Button onClick={() => setOpen(true)}>+ Thêm khách hàng</Button>{' '}
{/* lối thoát rõ ràng */}
</div>
);
}
| Trạng thái | UI nên có | Bẫy nếu bỏ qua |
|---|---|---|
| Loading | Skeleton trùng hình nội dung | layout “nhảy” khi data về (CLS) |
| Có dữ liệu | nội dung thật | — |
| Rỗng (empty) | thông điệp + lối thoát (nút thêm) | người dùng tưởng app hỏng |
| Lỗi | Alert + nút thử lại | màn trắng, mất niềm tin |
Vì sao skeleton hơn spinner: spinner nói “đang bận” nhưng giấu hình dạng sắp tới, gây giật khi data đổ vào. Skeleton giữ đúng kích thước/khung, nên trang “ổn định” suốt — đây là một trong những khác biệt cảm nhận rõ nhất giữa app nhanh và app “có vẻ chậm”.
9. Dark mode polish
Phần 10 đã dựng cơ chế dark mode bằng token semantic. Ở tầng “pro patterns”, vài chi tiết tách bản dark mode tạm bợ khỏi bản chỉn chu:
- Luôn dùng token semantic, không dùng màu thô.
bg-background text-foregroundtự đảo theo theme;bg-white text-slate-900thì kẹt cứng ở light mode. - Border mờ đi trong dark. Border đậm của light mode thường quá gắt khi nền tối — dùng token
border(đã chỉnh sẵn) thay vìborder-slate-200. - Nền nhấn dùng alpha, không dùng sắc độ cố định.
bg-emerald-500/15hoạt động đẹp ở cả hai theme;bg-emerald-100thì biến mất trên nền tối. - Ảnh/nội dung sáng cần dịu lại.
dark:brightness-90hoặc một overlay nhẹ tránh ảnh “chói” trên nền tối.
// ✅ pro: token semantic + alpha cho nền nhấn — đúng ở cả 2 theme
<div className="rounded-lg border bg-card text-card-foreground p-4">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">Active</Badge>
</div>
// ❌ kẹt light mode — phải viết lại mọi class cho dark:
<div className="rounded-lg border-slate-200 bg-white text-slate-900 p-4">…</div>
Mẹo test nhanh: bật dark mode rồi đảo mắt qua từng vùng. Bất cứ chỗ nào còn
bg-white,text-black,border-slate-*, hayshadowcứng đều là điểm rò rỉ. Quy tắc một câu: mọi màu trong app phải là token semantic, hoặc một alpha-tint — không có hex thô, không sắc độ cố định cho nền.
10. Performance — chống “class soup”, tách component, cva
Vấn đề hiệu năng lớn nhất của Tailwind không phải runtime (CSS đã tĩnh, rất nhanh) mà là khả năng bảo trì: khi một phần tử mọc 25 class lặp ở 10 nơi, sửa một chỗ là quên chín chỗ. “Class soup” là nợ kỹ thuật, không phải nợ tốc độ.
// ❌ class soup, lặp ở mọi nút — sửa một, sót chín
<button className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50">…</button>
// ✅ trích vào component + cva — một nguồn sự thật
<Button>…</Button>
Bốn đòn bẩy, theo thứ tự nên áp dụng:
- Trích thành component ngay khi một cụm class lặp lần thứ hai. Đây là cách xử lý lặp đúng trong Tailwind — không phải quay lại
@applyhay đặt tên CSS. cvacho biến thể thay vì if/else nối chuỗi class — giữ mọi biến thể có type, một chỗ.cn()để merge, để override không “đánh nhau” và bạn không nhân đôi class.@applychỉ cho vài thứ hiếm (ví dụ class hoá một phần tử bên thứ ba bạn không kiểm soát markup). Đừng dùng@applyđể né việc trích component — đó là đi lùi.
| Triệu chứng | Đòn bẩy | Vì sao |
|---|---|---|
| Cùng cụm class ≥ 2 nơi | trích component | một nguồn sự thật |
| Nhiều biến thể của một component | cva | có type, tập trung |
| Caller cần override class | cn() | merge an toàn, không đánh nhau |
| Bundle CSS lo phình | không cần lo | Tailwind chỉ sinh class thực dùng (Phần 1) |
Hiểu đúng để khỏi lo hão: Tailwind quét text tĩnh và chỉ sinh CSS cho class thực sự xuất hiện, nên CSS production thường chỉ vài kB bất kể app to cỡ nào. Tối ưu hiệu năng Tailwind = tối ưu khả năng đọc/bảo trì code, không phải cắt byte CSS.
11. Capstone — ráp Phần 1→11 lại
Dashboard trong demo đầu bài, viết bằng code shadcn thật, chỉ là một composition của những mảnh bạn giờ hiểu trọn vẹn:
export function DashboardPage() {
const [filter, setFilter] = React.useState('');
const [cmdOpen, setCmdOpen] = React.useState(false);
const { data, isLoading } = useCustomers(); // fetch
const filtered = React.useMemo(
() => (data ?? []).filter((c) => c.name.toLowerCase().includes(filter.toLowerCase())),
[data, filter],
);
return (
<AppShell> {/* §1 app-shell grid */}
<div className="space-y-6">
{/* hàng thẻ thống kê — Grid responsive (Phần 2) */}
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)} {/* §8 */}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
<StatCard label="MRR" value="$48.2k" delta={12.4} /> {/* §2 compound */}
<StatCard label="Người dùng" value="2,481" delta={4.1} />
<StatCard label="Churn" value="1.9%" delta={-0.3} />
</div>
)}
{/* thanh hành động */}
<div className="flex items-center gap-2">
<Input placeholder="Lọc khách hàng…" value={filter} onChange={(e) => setFilter(e.target.value)} className="max-w-xs" />
<Button variant="outline" onClick={() => setCmdOpen(true)} className="ml-auto">
Lệnh <kbd className="ml-1 text-xs">⌘K</kbd>
</Button>
<NewCustomerDialog /> {/* §6 Dialog + §7 toast khi lưu */}
</div>
{/* bảng — hoặc empty state */}
{!isLoading && filtered.length === 0
? <EmptyCustomers /> {/* §8 empty */}
: <CustomersTable data={filtered} />} {/* §5 data table + §5b row actions */}
</div>
<CommandMenu open={cmdOpen} onOpenChange={setCmdOpen} /> {/* ⌘K palette */}
</AppShell>
);
}
Đọc lại bằng “bản đồ phần”: AppShell (§1) ôm tất cả; StatCard là compound (§2); Button/Badge chạy trên cva+cn+asChild (§3–4); CustomersTable là headless + style (§5) với row actions DropdownMenu (§5b); NewCustomerDialog dùng Dialog (§6) và bắn toast khi lưu (§7); skeleton và empty lo các trạng thái (§8); tất cả theme đúng nhờ token semantic (§9); và không một dòng nào là “class soup” vì mọi cụm lặp đã thành component (§10). Không có mảnh nào là phép màu — bạn hiểu từng dòng.
12. Gotchas — những bẫy ở tầng “ráp lại”
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Topbar/sidebar cuộn theo nội dung | Cho cả trang cuộn thay vì chỉ main | overflow-hidden ở khung, overflow-y-auto ở main (§1) |
| Sticky header bị nội dung “xuyên qua” | Thiếu bg-* và z-index | sticky top-0 z-10 bg-background trên TableHeader |
Console cảnh báo “Missing DialogTitle” | Dialog/Sheet không có tiêu đề | Thêm DialogTitle (bọc VisuallyHidden nếu cần ẩn) |
asChild mất style/hành vi | Con không forward ref/...props, hoặc có 2 con | Một con duy nhất, forwardRef, spread props xuống DOM |
| Toast hiện chồng/không hiện | Quên <Toaster /> ở gốc, hoặc render nhiều <Toaster /> | Render đúng một <Toaster /> tại root |
| Dark mode “lủng” vài chỗ | Dùng màu thô (bg-white, border-slate-200) | Đổi sang token semantic / alpha-tint (§9) |
h-screen hụt đáy trên mobile | URL bar co/giãn | Dùng h-dvh thay h-screen |
| Bảng vỡ layout trên màn hẹp | Không có bọc cuộn | Bọc Table trong overflow-auto |
| Layout “nhảy” khi data về | Không có skeleton giữ chỗ | Skeleton trùng hình nội dung (§8) |
13. Production checklist
Bảng “trước khi merge” cho một màn dashboard — quét nhanh từng dòng:
| Hạng mục | Kiểm | Đạt khi |
|---|---|---|
| Layout | App-shell bằng grid, h-dvh, chỉ main cuộn | topbar/sidebar đứng yên ở mọi kích thước |
| Responsive | Có breakpoint cho mobile; bảng cuộn ngang; sidebar → Sheet | dùng được ở 360px |
| Trạng thái | Có đủ loading / data / empty / error | không vùng nào trống trơn hay nhảy layout |
| A11y | DialogTitle, sr-only cho icon-button, focus nhìn thấy | dùng được hoàn toàn bằng bàn phím |
| Dark mode | Toàn token semantic / alpha; không màu thô | đẹp ở cả hai theme, không chỗ lủng |
| Variant | cva + cn cho mọi component tương tác | API nhất quán, override an toàn |
| Composition | Compound thay vì god-component; asChild cho link | không prop “showX” mọc tràn lan |
| Phản hồi | Toast cho thoáng qua; Alert/Dialog cho lỗi nặng | người dùng luôn biết chuyện gì xảy ra |
| Hiệu năng/bảo trì | Không class soup; cụm lặp đã trích component | sửa một chỗ, đổi mọi nơi |
| Headless logic | Sort/filter/paginate ở TanStack; logic ở lib/ | component thuần trình bày, dễ test |
14. Thói quen của một pro
- Thấy cụm class lặp lần hai → trích component ngay, đừng đợi lần ba.
- Mọi màu là token semantic hoặc alpha-tint — không bao giờ hex thô trong markup.
- Mọi component tương tác theo khuôn
cva+cn+asChild— API đoán được. - Thiết kế cho cả bốn trạng thái (loading/data/empty/error), không chỉ “đầy đủ”.
- Để Radix lo a11y (focus, ARIA, bàn phím) — đừng tự phát minh lại modal/menu.
- Logic ở
lib/, trình bày ở component — bảng/form chỉ vẽ, không “biết” nghiệp vụ. - Đọc mã nguồn component shadcn bạn dùng — giờ bạn hiểu hết, nên học được rất nhanh.
15. Bài tập
1. Trong mẫu data-table, TanStack Table giữ gì và bạn giữ gì?
Lời giải
TanStack giữ logic headless — row model sau sắp xếp/lọc/phân trang. Bạn giữ markup và style Tailwind qua primitive Table. Ranh giới này là lý do bảng của bạn theme và responsive giống hệt phần còn lại của app.
2. Vì sao trong app-shell ta đặt overflow-hidden ở khung và overflow-y-auto ở main, thay vì để cả trang cuộn?
Lời giải
Để chỉ vùng main cuộn, còn topbar và sidebar đứng yên một cách tự nhiên — không cần position: sticky/fixed. Nếu để cả trang cuộn, topbar/sidebar sẽ trôi theo nội dung.
3. asChild giải bài toán gì, và điều kiện để nó hoạt động là gì?
Lời giải
Nó cho một component (vd Button) mượn thẻ của con — render <a>/<Link> mà vẫn giữ style/hành vi, tránh lồng thẻ sai. Điều kiện: con là một node duy nhất và phải forward ref + spread ...props xuống DOM.
4. Khi nào dùng toast, khi nào không?
Lời giải
Dùng toast cho phản hồi thoáng qua, không bắt buộc đọc (đã lưu, đã copy, undo). Không dùng cho lỗi validation hay thông tin người dùng phải đọc — toast tự biến mất nên dễ bỏ lỡ; những thứ đó dùng Alert inline hoặc Dialog.
5. Một component StatCard của bạn đang có 6 prop: title, value, delta, icon, footer, showChart. Pattern nào nên áp dụng, và vì sao?
Lời giải
Chuyển sang compound component (Card + CardHeader/CardTitle/CardContent…). Số prop đang phình là tín hiệu cần “phơi các phần” để caller tự ghép — API ổn định lại, và họ chèn được nội dung tuỳ ý (chart, footer) mà không cần thêm prop mới.
6. Vì sao bg-emerald-500/15 là lựa chọn tốt hơn bg-emerald-100 cho nền badge khi app có dark mode?
Lời giải
/15 là alpha-tint (15% độ mờ) phủ lên nền hiện tại, nên nó đẹp ở cả light và dark. bg-emerald-100 là sắc độ sáng cố định — trên nền tối nó chói hoặc biến mất, buộc bạn phải viết thêm biến thể dark:.
Nâng cao: trong capstone, mở ⌘K, chạy “Sort by name”, rồi gõ vào ô lọc — để ý ba tính năng độc lập (palette, sort, filter) kết hợp mà không xung đột, vì mỗi cái giữ state riêng và bảng chỉ phản ánh model cuối.
Điểm chính
- App-shell là bài toán layout, không phải positioning: một grid hai chiều (
grid-cols-[16rem_1fr]+h-dvh), chỉmaincuộn — sidebar/topbar đứng yên mà không cầnfixed. - Compound > nhồi prop,
asChildcho đa hình: phơi các phần để caller ghép; choButtonmượn thẻ<a>/<Link>quaSlotmà không mất style. - Mọi component tương tác chung một DNA:
cva(variant có type) +cn(merge an toàn) +Slot(đa hình). Nhận ra nó là đọc được mọi component shadcn. - Data table = logic headless (TanStack) + style của bạn; sticky header, bọc cuộn responsive, row actions bằng
DropdownMenu— đồng nhất với phần còn lại của app. - Dialog/Sheet/AlertDialog là một primitive Radix ba hình — bạn được tặng focus-trap, ARIA, Escape; chỉ cần
DialogTitle. - Thiết kế cho bốn trạng thái (loading/data/empty/error): skeleton trùng hình để khỏi nhảy layout, empty state có lối thoát; toast chỉ cho phản hồi thoáng qua.
- Dark mode chỉn chu = token semantic + alpha-tint, không màu thô. Hiệu năng = bảo trì: trích component khi cụm class lặp, để Tailwind lo phần CSS nhỏ gọn.
Tiếp theo
Bạn đã hoàn thành series. Bạn không còn copy-paste mù — bạn hiểu cả stack: Tailwind sinh CSS thế nào, vì sao utility-first thắng, cách Radix tách hành vi khỏi giao diện, và vì sao shadcn cho bạn sở hữu code.
Từ đây, bốn hướng để biến hiểu biết thành phản xạ:
- Dựng một project thật từ đầu đến cuối — chính dashboard capstone này là điểm khởi đầu tốt: thêm route, auth, và một backend thật.
- Đọc mã nguồn mọi component shadcn bạn dùng. Giờ bạn hiểu
cva+cn+Slot+ Radix, nên mỗi component là một bài học 5 phút, không còn là hộp đen. - Khám phá registry để publish design system của riêng bạn — đóng gói các pattern ở bài này thành component dùng lại được cho cả team.
- Theo dõi changelog Tailwind và Radix. Nền tảng tiến nhanh; bạn đã có mô hình tư duy để tiếp thu cái mới mà không phải học lại từ đầu.
Cảm ơn bạn đã đi tới capstone. Hai bài tiếp theo là tài liệu tham chiếu để bạn quay lại khi làm việc thật: variant deep reference và Tailwind v4 production cheatsheet.