Selectable Table
A table with a built-in checkbox column and select-all with indeterminate state. Pagination is composed externally with Pagination and usePagination.
Installation
Usage
SelectableTable wraps Table and prepends a checkbox column. Selection is a plain array of row keys — pass selected + onSelectionChange for controlled usage, or defaultSelected alone. rowKey is required: it identifies rows and doubles as the selection key.
Select-all only affects the current page and preserves selections from other pages, so cross-page accumulation is yours to manage (e.g. keep collecting keys across fetches).
| Task | Owner | Priority |
|---|
| Design review | Ada | high | |
| API integration | Linus | medium | |
| Write tests | Grace | high | |
| Release notes | Ada | low | |
| Fix flaky test | Linus | high | |
| Update docs | Grace | medium | |
| Refactor hooks | Ada | low | |
| Optimize bundle | Linus | high | |
| Add telemetry | Grace | medium | |
| Rotate keys | Ada | high | |
| Patch CVE | Linus | low | |
| Migrate config | Grace | medium |
0 selected
"use client";import * as React from "react";import { SelectableTable } from "@/ui";interface Task { id: string; title: string; owner: string; priority: "high" | "medium" | "low";}const tasks: Task[] = [ { id: "t-01", title: "Design review", owner: "Ada", priority: "high" }, { id: "t-02", title: "API integration", owner: "Linus", priority: "medium" }, { id: "t-03", title: "Write tests", owner: "Grace", priority: "high" }, { id: "t-04", title: "Release notes", owner: "Ada", priority: "low" }, { id: "t-05", title: "Fix flaky test", owner: "Linus", priority: "high" }, { id: "t-06", title: "Update docs", owner: "Grace", priority: "medium" }, { id: "t-07", title: "Refactor hooks", owner: "Ada", priority: "low" }, { id: "t-08", title: "Optimize bundle", owner: "Linus", priority: "high" }, { id: "t-09", title: "Add telemetry", owner: "Grace", priority: "medium" }, { id: "t-10", title: "Rotate keys", owner: "Ada", priority: "high" }, { id: "t-11", title: "Patch CVE", owner: "Linus", priority: "low" }, { id: "t-12", title: "Migrate config", owner: "Grace", priority: "medium" },];export default function Demo() { const [selected, setSelected] = React.useState<string[]>([]); return ( <div className="w-full max-w-xl"> <SelectableTable data={tasks} rowKey={(row) => row.id} selected={selected} onSelectionChange={setSelected} columns={[ { key: "title", header: "Task" }, { key: "owner", header: "Owner" }, { key: "priority", header: "Priority", render: (row) => ( <span className={ row.priority === "high" ? "font-medium text-danger" : row.priority === "medium" ? "text-warning" : "text-muted-foreground" } > {row.priority} </span> ), }, ]} /> <p className="mt-2 text-sm text-muted-foreground">{selected.length} selected</p> </div> );}Composition with Pagination
The table renders whatever data holds; paging is composed externally. Let usePagination own the page state, pass its index and goTo into Pagination, fetch the current page into data yourself, and place the table, the status text and the pager inside your own footer layout. The live result is in the preview below — open its code for the exact wiring.
| ID | Name | Village |
|---|
| r-01 | Ada | Eastwood | |
| r-02 | Linus | Riverside | |
| r-03 | Grace | Hillcrest | |
| r-04 | Edsger | Lakeside | |
| r-05 | Hopper | Eastwood |
"use client";import * as React from "react";import { Button, Pagination, SelectableTable, usePagination } from "@/ui";import { Download } from "lucide-react";interface Resident { id: string; name: string; village: string;}const TOTAL = 23;const PAGE_SIZE = 5;const names = ["Ada", "Linus", "Grace", "Edsger", "Hopper", "Dijkstra"];const villages = ["Eastwood", "Riverside", "Hillcrest", "Lakeside"];const residents: Resident[] = Array.from({ length: TOTAL }, (_, i) => ({ id: `r-${String(i + 1).padStart(2, "0")}`, name: names[i % names.length], village: villages[i % villages.length],}));const totalPages = Math.ceil(TOTAL / PAGE_SIZE);export default function Demo() { const pagination = usePagination({ base: 1, total: totalPages }); const [data, setData] = React.useState(() => residents.slice(0, PAGE_SIZE)); const [loading, setLoading] = React.useState(false); const skipFirst = React.useRef(true); React.useEffect(() => { if (skipFirst.current) { skipFirst.current = false; return; } setLoading(true); const timer = window.setTimeout(() => { setData(residents.slice((pagination.index - 1) * PAGE_SIZE, pagination.index * PAGE_SIZE)); setLoading(false); }, 400); return () => window.clearTimeout(timer); }, [pagination.index]); const [selected, setSelected] = React.useState<string[]>([]); return ( <div className="flex w-full max-w-xl flex-col gap-2"> <SelectableTable data={data} rowKey={(row) => row.id} selected={selected} onSelectionChange={setSelected} loading={loading} columns={[ { key: "id", header: "ID" }, { key: "name", header: "Name" }, { key: "village", header: "Village" }, ]} /> <div className="flex items-center justify-between gap-2 text-sm text-muted-foreground"> <span className="min-w-0 truncate">{selected.length} selected · {TOTAL} total</span> <div className="flex shrink-0 items-center gap-1"> <Button variant="text" aria-label="Export selected" disabled={selected.length === 0}> <Download /> </Button> <Pagination page={pagination.index} totalPages={totalPages} onPageChange={pagination.goTo} disabled={loading} /> </div> </div> </div> );}Large datasets
For row counts too large to render at once, pair the TableBody scroller with useVirtualScroll: the hook's container props attach to the scroller and its visible-window output drives which rows render.
The selection column lives under the internal key __selection — avoid a data field with that name.
API Reference
SelectableTable<T>
| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | - | Row data (the current page). Required |
columns | TableColumn<T>[] | - | Column config, the checkbox column is prepended automatically. Required |
rowKey | (row: T) => string | - | Row key extractor and selection unit. Required |
selected | string[] | - | Controlled selection (row keys) |
defaultSelected | string[] | [] | Initial selection for uncontrolled usage |
onSelectionChange | (selected: string[]) => void | - | Fired on every row / select-all toggle |
loading | boolean | false | Default empty text becomes "Loading..." |
empty | React.ReactNode | "No data" | Rendered when data is empty |
className | ClassNameValue | - | Custom classes, forwarded to the inner Table |
classNames | Table slots | - | Custom classes per part |
styles | { wrapper?, table? } | - | Forwarded to the inner Table |
All remaining Table props (sort / onSortChange, getKey, …) are inherited; getKey is derived from rowKey.