jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ồngCâu hỏi kiến trúc
LayoutVùng nào đứng yên, vùng nào cuộn, mobile chuyển hình ra sao?
DataTrạng thái sort/filter/pagination nằm ở đâu, URL có cần đồng bộ không?
InteractionDialog/sheet/menu có đúng focus, Esc, click outside, keyboard không?
FeedbackLoading/empty/error/success có được thiết kế như state thật không?
SystemComponent 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 cho h-screen — trên mobile, thanh địa chỉ trình duyệt co/giãn; dvh tính đúng chiều cao thực, không bị “hụt đáy”.
  • grid-cols-[16rem_1fr]: sidebar cố định 16rem, content nuốt phần còn lại 1fr. Không margin-left thủ 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-automain: chỉ main cuộn, topbar và sidebar đứng yên một cách tự nhiên mà không cần position: sticky.
  • hidden md:block trên sidebar: dưới md sidebar biến mất và ta chuyển sang Sheet (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 propCompound component
Mỗi biến thể mới = thêm một propCaller ghép phần có sẵn, không sửa component
Khó sắp xếp lại thứ tự nội dungThứ tự = thứ tự JSX, tự nhiên
API phình theo thời gianAPI ổn định: chỉ là các “mảnh ghép”
Khó chèn nội dung tùy ý vào giữaChè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ốngKhông có asChildasChild
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-buttonDOM thừamerge 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 + ref xuống DOM (vì sao đó component custom của bạn dùng làm con phải forwardRef). 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 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ảngAi loBạn viết gì
Sắp xếp / lọc / phân trangTanStack (getSortedRowModel…)bật model + nối state
Sticky headerTailwindsticky top-0 z-10 bg-background trên TableHeader
ResponsiveTailwindbọc overflow-auto quanh Table
Row actionsRadix DropdownMenumột menu, asChild trên trigger
Empty statebạnhà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ùngChọnVì sao
Xác nhận, form ngắnDialogtiêu điểm vào giữa, ngắn gọn
Nav mobile, panel filter, chi tiết dàiSheettrượt từ cạnh, không che hết
Cảnh báo phá huỷ (xoá…)AlertDialogbắ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 cho aria-labelledby/aria-describedby; thiếu DialogTitle, 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ó trong VisuallyHidden chứ đừ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ồiDù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 đượctoast(..., { 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 Alert inline 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áiUI nên cóBẫy nếu bỏ qua
LoadingSkeleton trùng hình nội dunglayout “nhảy” khi data về (CLS)
Có dữ liệunộ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ỗiAlert + nút thử lạimà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-foreground tự đảo theo theme; bg-white text-slate-900 thì 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/15 hoạt động đẹp ở cả hai theme; bg-emerald-100 thì biến mất trên nền tối.
  • Ảnh/nội dung sáng cần dịu lại. dark:brightness-90 hoặ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-*, hay shadow cứ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:

  1. 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 @apply hay đặt tên CSS.
  2. cva cho biến thể thay vì if/else nối chuỗi class — giữ mọi biến thể có type, một chỗ.
  3. cn() để merge, để override không “đánh nhau” và bạn không nhân đôi class.
  4. @apply chỉ 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ẩyVì sao
Cùng cụm class ≥ 2 nơitrích componentmột nguồn sự thật
Nhiều biến thể của một componentcvacó type, tập trung
Caller cần override classcn()merge an toàn, không đánh nhau
Bundle CSS lo phìnhkhông cần loTailwind 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ứngNguyên nhânCách xử lý
Topbar/sidebar cuộn theo nội dungCho cả trang cuộn thay vì chỉ mainoverflow-hidden ở khung, overflow-y-automain (§1)
Sticky header bị nội dung “xuyên qua”Thiếu bg-*z-indexsticky top-0 z-10 bg-background trên TableHeader
Console cảnh báo “Missing DialogTitleDialog/Sheet không có tiêu đềThêm DialogTitle (bọc VisuallyHidden nếu cần ẩn)
asChild mất style/hành viCon không forward ref/...props, hoặc có 2 conMột con duy nhất, forwardRef, spread props xuống DOM
Toast hiện chồng/không hiệnQuê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 mobileURL bar co/giãnDùng h-dvh thay h-screen
Bảng vỡ layout trên màn hẹpKhông có bọc cuộnBọ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ụcKiểmĐạt khi
LayoutApp-shell bằng grid, h-dvh, chỉ main cuộntopbar/sidebar đứng yên ở mọi kích thước
ResponsiveCó breakpoint cho mobile; bảng cuộn ngang; sidebar → Sheetdùng được ở 360px
Trạng tháiCó đủ loading / data / empty / errorkhông vùng nào trống trơn hay nhảy layout
A11yDialogTitle, sr-only cho icon-button, focus nhìn thấydùng được hoàn toàn bằng bàn phím
Dark modeToàn token semantic / alpha; không màu thôđẹp ở cả hai theme, không chỗ lủng
Variantcva + cn cho mọi component tương tácAPI nhất quán, override an toàn
CompositionCompound thay vì god-component; asChild cho linkkhông prop “showX” mọc tràn lan
Phản hồiToast cho thoáng qua; Alert/Dialog cho lỗi nặngngườ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 componentsửa một chỗ, đổi mọi nơi
Headless logicSort/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-automain, 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

/15alpha-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ỉ main cuộn — sidebar/topbar đứng yên mà không cần fixed.
  • Compound > nhồi prop, asChild cho đa hình: phơi các phần để caller ghép; cho Button mượn thẻ <a>/<Link> qua Slot mà 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ạ:

  1. 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.
  2. Đọ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.
  3. 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.
  4. 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.