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.

You’ve learned every layer {Bạn đã học mọi tầng}: Tailwind utilities and tokens, variants, cva/cn, Radix behavior, and shadcn’s component model {utility và token Tailwind, variant, cva/cn, hành vi Radix, và mô hình component của shadcn}. This finale assembles them into the patterns you’ll actually ship — a data table, a command palette, toasts — and a dashboard that uses them together {Phần cuối ráp tất cả thành các mẫu bạn thật sự ship — data table, command palette, toast — và một dashboard dùng chúng cùng nhau}.

Press ⌘K/Ctrl K, sort columns, filter, and trigger toasts in the live capstone {Bấm ⌘K/Ctrl K, sắp xếp cột, lọc, và bật toast trong capstone}:


1. A scalable variant system {Hệ variant scale được}

For a design system, centralize variants with cva and expose typed components {Cho một design system, tập trung variant bằng cva và phơi component có type}. The pattern from Part 5, applied consistently, gives you a predictable API surface across the whole app {Mẫu ở Phần 5, áp dụng nhất quán, cho bạn một bề mặt API dự đoán được trên toàn app}:

// every interactive component follows the same shape
const badgeVariants = cva('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold', {
  variants: {
    variant: {
      default: 'bg-primary text-primary-foreground',
      success: 'bg-emerald-500/15 text-emerald-500',
      warning: 'bg-amber-500/15 text-amber-500',
      destructive: 'bg-destructive/15 text-destructive',
    },
  },
  defaultVariants: { variant: 'default' },
});

Consistency is the system: same variant vocabulary, same cn(..., className) override, same asChild escape hatch on everything {Sự nhất quán chính là hệ thống: cùng vốn từ variant, cùng override cn(..., className), cùng cửa thoát asChild ở mọi thứ}.


2. Data table — TanStack Table + shadcn {Data table — TanStack Table + shadcn}

shadcn’s data table is headless logic (TanStack Table) + your styled Table primitives — the same headless philosophy as Radix, applied to tables {Data table của shadcn là logic headless (TanStack Table) + các primitive Table đã style của bạn — cùng triết lý headless như Radix, áp cho bảng}:

npm install @tanstack/react-table
npx shadcn@latest add table
import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, flexRender } from '@tanstack/react-table';

const columns: ColumnDef<Customer>[] = [
  { accessorKey: 'name', header: 'Customer' },
  { accessorKey: 'plan', header: 'Plan' },
  { accessorKey: 'mrr', header: 'MRR', cell: ({ row }) => `$${row.getValue('mrr')}` },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => <Badge variant={statusVariant(row.getValue('status'))}>{row.getValue('status')}</Badge>,
  },
];

function CustomersTable({ data }: { data: Customer[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const table = useReactTable({
    data, columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((hg) => (
          <TableRow key={hg.id}>
            {hg.headers.map((h) => (
              <TableHead key={h.id} onClick={h.column.getToggleSortingHandler()} className="cursor-pointer">
                {flexRender(h.column.columnDef.header, h.getContext())}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {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>
        ))}
      </TableBody>
    </Table>
  );
}

TanStack owns sorting/filtering/pagination state; you own the markup and styles {TanStack giữ state sắp xếp/lọc/phân trang; bạn giữ markup và style}. The capstone above mirrors this — click a header to sort, type to filter {Capstone ở trên phản ánh điều này — bấm header để sắp xếp, gõ để lọc}.


3. Command palette — cmdk {Command palette — cmdk}

The ⌘K menu (Linear, Raycast, Vercel) is cmdk, which shadcn wraps as Command {Menu ⌘K (Linear, Raycast, Vercel) là cmdk, shadcn bọc thành Command}:

npx shadcn@latest add command
import { CommandDialog, CommandInput, CommandList, CommandItem, CommandGroup, CommandEmpty } from '@/components/ui/command';

function CommandMenu() {
  const [open, setOpen] = useState(false);

  // global ⌘K / Ctrl+K listener
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((o) => !o); }
    };
    document.addEventListener('keydown', down);
    return () => document.removeEventListener('keydown', down);
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command…" />
      <CommandList>
        <CommandEmpty>No results.</CommandEmpty>
        <CommandGroup heading="Actions">
          <CommandItem onSelect={() => addCustomer()}>Add customer</CommandItem>
          <CommandItem onSelect={() => exportCsv()}>Export CSV</CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
}

cmdk handles fuzzy filtering, keyboard nav, and grouping; CommandDialog is just Command inside a Radix Dialog {cmdk lo lọc mờ, điều hướng bàn phím, và nhóm; CommandDialog chỉ là Command trong một Dialog Radix}.


4. Toasts — sonner {Toast — sonner}

shadcn moved to sonner for toasts — imperative, gorgeous, zero boilerplate {shadcn chuyển sang sonner cho toast — gọi mệnh lệnh, đẹp, không boilerplate}:

npx shadcn@latest add sonner
// app root — render once
import { Toaster } from '@/components/ui/sonner';
<Toaster richColors />

// anywhere — call it
import { toast } from 'sonner';
toast.success('Customer added');
toast.error('Something went wrong');
toast.promise(saveCustomer(), { loading: 'Saving…', success: 'Saved', error: 'Failed' });

One <Toaster /> at the root, then toast() from anywhere — no context wiring per call site {Một <Toaster /> ở gốc, rồi toast() từ bất kỳ đâu — không nối context theo từng nơi gọi}.


5. Composition patterns that scale {Mẫu kết hợp scale được}

A few habits separate a tidy codebase from a tangled one {Vài thói quen phân biệt codebase gọn với codebase rối}:

  • Compound components — expose related parts together (Card + CardHeader + CardContent) instead of a mega-props component {phơi các phần liên quan cùng nhau thay vì một component nhiều prop}.
  • asChild everywhere it helps — a Button that becomes a Link without losing styles {asChild ở mọi nơi hữu ích — Button hóa Link mà không mất style}.
  • Wrap third-party primitives once — your DataTable, CommandMenu, theme provider live in components/, configured the way your app needs {bọc primitive bên thứ ba một lần}.
  • Keep logic out of UI — sorting, fetching, schemas in lib/; components stay presentational {tách logic khỏi UI}.

6. The capstone, in components {Capstone, theo component}

The dashboard above is, in real shadcn code, a small composition {Dashboard ở trên, trong code shadcn thật, là một composition nhỏ}:

export function Dashboard() {
  return (
    <div className="space-y-4 p-6">
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
        <StatCard label="MRR" value="$48.2k" delta="+12.4%" />
        <StatCard label="Active users" value="2,481" delta="+4.1%" />
        <StatCard label="Churn" value="1.9%" delta="-0.3%" />
      </div>

      <div className="flex items-center gap-2">
        <Input placeholder="Filter customers…" onChange={(e) => setFilter(e.target.value)} />
        <Button variant="outline" onClick={() => setCmdOpen(true)}>Command ⌘K</Button>
        <Button onClick={() => toast.success('Customer added')}>+ Add customer</Button>
      </div>

      <CustomersTable data={filtered} />
      <CommandMenu open={cmdOpen} onOpenChange={setCmdOpen} />
    </div>
  );
}

Every piece is something you now understand top to bottom — the grid (Part 2), tokens (Parts 3, 10), variants (Parts 4, 5), Radix-powered command/dialog (Parts 7, 8), shadcn components (Parts 9–11) {Mỗi mảnh đều là thứ bạn giờ hiểu từ trên xuống dưới}.


7. Exercises {Bài tập}

1. In the data-table pattern, what does TanStack Table own and what do you own? {Trong mẫu data-table, TanStack Table giữ gì và bạn giữ gì?}

Solution {Lời giải}

TanStack owns the headless logic (sorting/filtering/pagination state and row models); you own the markup and Tailwind styling via the Table primitives {TanStack giữ logic headless; bạn giữ markup và style Tailwind qua primitive Table}.

2. Why is CommandDialog “just Command inside a Dialog”? {Vì sao CommandDialog “chỉ là Command trong một Dialog”?}

Solution {Lời giải}

The palette UI (cmdk = Command) is rendered inside a Radix Dialog for the overlay, focus trap, and Escape handling — composition over reinvention {UI palette được render trong một Dialog Radix để có overlay, bẫy focus, và xử lý Escape — kết hợp thay vì phát minh lại}.

3. Where should sorting/filtering logic live, and why? {Logic sắp xếp/lọc nên đặt ở đâu, và vì sao?}

Solution {Lời giải}

In lib/ (or table config), keeping components presentational — easier to test, reuse, and reason about {Trong lib/ (hoặc config bảng), giữ component thuần trình bày — dễ test, tái dùng, và suy luận}.

Stretch {Nâng cao}: in the capstone, open ⌘K, run “Sort by name”, then filter the table — note how three independent features compose without interfering {trong capstone, mở ⌘K, chạy “Sort by name”, rồi lọc bảng — để ý ba tính năng độc lập kết hợp mà không xung đột}.


Key takeaways {Điểm chính}

  • A design system is consistency: every component shares the cva variant + cn override + asChild shape {Một design system là sự nhất quán: mọi component chung hình cva + cn + asChild}.
  • Data tables (TanStack), command palettes (cmdk), and toasts (sonner) follow the headless-logic + your-styles model {Data table, command palette, toast theo mô hình logic headless + style của bạn}.
  • Compose primitives, wrap third-party libs once, and keep logic in lib/ {Kết hợp primitive, bọc lib bên thứ ba một lần, giữ logic trong lib/}.
  • You can now read, build, theme, and extend a full Tailwind + Radix + shadcn app — that’s pro {Giờ bạn đọc, dựng, theme, và mở rộng được một app Tailwind + Radix + shadcn đầy đủ — đó là pro}.

Where to go next {Đi đâu tiếp}

You’ve completed the series {Bạn đã hoàn thành series}. From here {Từ đây}: build a real project end-to-end, read the source of every shadcn component you use (you’ll understand all of it now), explore the registry to publish your own design system, and keep an eye on the Tailwind and Radix changelogs {dựng một project thật từ đầu đến cuối, đọc mã nguồn mọi component shadcn bạn dùng (giờ bạn hiểu hết), khám phá registry để publish design system của mình, và theo dõi changelog của Tailwind và Radix}. You’re no longer copy-pasting — you understand the whole stack {Bạn không còn copy-paste — bạn hiểu cả stack}.