Table
A generic data table with clickable sortable headers for local data, or remote mode that delegates sorting to your database.
Installation
Usage
Table is data-driven: pass data plus a columns config. Set sortable: true on a column to make its header clickable — the sort cycles asc → desc → cleared and applies locally to data.
| Task | Owner | Priority | Done |
|---|
| Design review | Ada | high | ✓ |
| API integration | Linus | medium | — |
| Write tests | Grace | high | — |
| Release notes | Ada | low | ✓ |
"use client";import { Table } from "@/ui";interface Task { title: string; owner: string; priority: "high" | "medium" | "low"; done: boolean;}const tasks: Task[] = [ { title: "Design review", owner: "Ada", priority: "high", done: true }, { title: "API integration", owner: "Linus", priority: "medium", done: false }, { title: "Write tests", owner: "Grace", priority: "high", done: false }, { title: "Release notes", owner: "Ada", priority: "low", done: true },];export default function Demo() { return ( <div className="w-full max-w-xl"> <Table data={tasks} getKey={(row) => row.title} columns={[ { key: "title", header: "Task", sortable: true }, { key: "owner", header: "Owner", sortable: true }, { key: "priority", header: "Priority", sortable: true, render: (row) => ( <span className={ row.priority === "high" ? "font-medium text-danger" : row.priority === "medium" ? "text-warning" : "text-muted-foreground" } > {row.priority} </span> ), }, { key: "done", header: "Done", align: "right", render: (row) => (row.done ? "✓" : "—") }, ]} /> </div> );}Remote sorting (database)
Pass a controlled sort + onSortChange to enter remote mode: the Table stops sorting internally and only reports header clicks, so the query can run against your database. Pair with useRemoteSort, which manages the state and builds the query string.
| Name | Category | Price | Stock |
|---|
| Mechanical keyboard | Peripherals | $129 | 42 |
| Wireless mouse | Peripherals | $49 | 120 |
| 4K monitor | Displays | $349 | 18 |
| USB-C hub | Accessories | $39 | 200 |
| Laptop stand | Accessories | $29 | 75 |
Remote mode: sorting is delegated to the database via (unsorted)
"use client";import * as React from "react";import { Table, useRemoteSort } from "@/ui";interface Product { name: string; category: string; price: number; stock: number;}const allProducts: Product[] = [ { name: "Mechanical keyboard", category: "Peripherals", price: 129, stock: 42 }, { name: "Wireless mouse", category: "Peripherals", price: 49, stock: 120 }, { name: "4K monitor", category: "Displays", price: 349, stock: 18 }, { name: "USB-C hub", category: "Accessories", price: 39, stock: 200 }, { name: "Laptop stand", category: "Accessories", price: 29, stock: 75 },];export default function Demo() { const remote = useRemoteSort<keyof Product>({ sortableKeys: ["name", "price", "stock"], onSortChange: (sort) => { // In production: refetch with ?sortKey=...&sortOrder=... console.log("query:", `?sortKey=${sort.key}&sortOrder=${sort.direction}`); }, }); const sorted = React.useMemo(() => { const active = remote.sort; if (!active) return allProducts; const { key, direction } = active; return [...allProducts].sort((a, b) => { const cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0; return direction === "asc" ? cmp : -cmp; }); }, [remote.sort]); return ( <div className="flex w-full max-w-xl flex-col gap-2"> <Table data={sorted} sort={remote.sort} onSortChange={remote.setSort} columns={[ { key: "name", header: "Name", sortable: true, render: (row) => <span className="font-medium">{row.name}</span>, }, { key: "category", header: "Category" }, { key: "price", header: "Price", sortable: true, align: "right", render: (row) => `$${row.price}` }, { key: "stock", header: "Stock", sortable: true, align: "right" }, ]} /> <p className="text-xs text-muted-foreground"> Remote mode: sorting is delegated to the database via{" "} <code className="rounded bg-muted px-1">{remote.queryString || "(unsorted)"}</code> </p> </div> );}Sticky header with a scrollable body
The header and the body are two separate tables: the header strip never scrolls (plain overflow-hidden, no scrollbar, no gutter), and the body scroller sits below it with a built-in fixed height (h-96), so the table never collapses when a page has fewer rows. Columns use table-fixed with equal widths; give a column a width by putting a w-* class in its className (it applies to both the header cell and the body cells). Override it via classNames.body (e.g. h-48, or h-auto plus min-w-* on classNames.table for wide tables).
| Task | Owner | Priority |
|---|
| Design review | Ada | high |
| API integration | Linus | medium |
| Write tests | Grace | low |
| Release notes | Edsger | high |
| Fix flaky test | Ada | medium |
| Update docs | Linus | low |
| Refactor hooks | Grace | high |
| Optimize bundle | Edsger | medium |
| Add telemetry | Ada | low |
| Rotate keys | Linus | high |
| Patch CVE | Grace | medium |
| Migrate config | Edsger | low |
"use client";import { Table } from "@/ui";interface Task { title: string; owner: string; priority: "high" | "medium" | "low"; done: boolean;}const titles = [ "Design review", "API integration", "Write tests", "Release notes", "Fix flaky test", "Update docs", "Refactor hooks", "Optimize bundle", "Add telemetry", "Rotate keys", "Patch CVE", "Migrate config",];const owners = ["Ada", "Linus", "Grace", "Edsger"];const priorities = ["high", "medium", "low"] as const;const tasks: Task[] = titles.map((title, i) => ({ title, owner: owners[i % owners.length], priority: priorities[i % priorities.length], done: i % 3 === 0,}));export default function Demo() { return ( <div className="w-full max-w-xl"> <Table data={tasks} getKey={(row) => row.title} classNames={{ body: "h-48" }} columns={[ { key: "title", header: "Task", sortable: true }, { key: "owner", header: "Owner", sortable: true }, { key: "priority", header: "Priority" }, ]} /> </div> );}Composable parts
Table is assembled from four atoms, so custom tables (extra header content, non-data tables, virtualized rows) can reuse the same frame:
The skeleton is always the same: TableRoot wraps a TableHead strip holding one TableBase with the header row, and a TableBody scroller holding one TableBase with the tbody. Keep the <colgroup> identical in both tables (with table-fixed it controls the shared column widths).
API Reference
Table
| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | - | Row data. Required |
columns | TableColumn<T>[] | - | Column config. Required |
getKey | (row: T, index: number) => React.Key | index | Row key extractor |
sort | SortState<T> | null | - | Controlled sort state; enables remote mode |
onSortChange | (sort: SortState<T>) => void | - | Fired on header clicks (both modes) |
initialSortDirection | "asc" | "desc" | "asc" | Direction used on the first click of a column |
empty | React.ReactNode | "No data" | Rendered when data is empty |
className | ClassNameValue | - | Custom classes, applied to the outer frame |
classNames | wrapper / table / head / headRow / headCell / body / row / cell | - | Custom classes per part (head / body target the header strip and the body scroller) |
styles | { wrapper?, table? } | - | Inline styles for the wrapper and both tables |
TableColumn<T>
| Field | Type | Default | Description |
|---|---|---|---|
key | keyof T & string | - | Row field to display. Required |
header | React.ReactNode | key | Header label |
render | (row: T) => React.ReactNode | raw value | Custom cell renderer |
sortable | boolean | false | Make the header click-to-sort |
compare | (a: T, b: T) => number | < / > | Custom comparator used by local sorting |
align | "left" | "right" | "center" | "left" | Cell alignment |
className | ClassNameValue | - | Custom classes applied to the column's header cell and body cells (e.g. w-* for column widths) |
Parts
| Part | Element | Built-in classes | Description |
|---|---|---|---|
TableRoot | <div> | w-full overflow-hidden rounded-lg border | Outer frame |
TableHead | <div> | overflow-hidden | Header strip; never scrolls |
TableBody | <div> | h-96 overflow-y-auto | Body scroller |
TableBase | <table> | w-full table-fixed border-collapse text-sm | Shared table element |
All parts accept native element props plus className.
SortState<T>
{ key: keyof T & string; direction: "asc" | "desc" } | null