Table
Rows and columns in real table markup, with sortable headers, checkbox selection, and TanStack Table when you want it.
5 workflow runs
- Click a header, or press Enter on it: ascending, then descending. A run still going has no duration and stays last.
- Tick runs; the header box shows a partial selection. Re-run sends the ticked ones back to running.
- On a narrow screen the table scrolls sideways and takes Tab, so the arrows scroll it.
Field marks
- Import
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from "cennge-ui";- Size
- 2.4 KB gzip for this import, its dependencies aside
- Pattern
- Native
<table>; sortable headers carryaria-sort - Keyboard
- Enter Space
- State
- Yours:
useTableSortanduseTableSelectionhold the sort and the selection, or TanStack Table does - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- TanStack Table
- No adapter:
TableColumntakesgetIsSorted()andgetToggleSortingHandler()as they are - Too wide
- Scrolls sideways as a named region that takes Tab
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: table
- Source
- src/components/table
When to use#
Use a Table when people compare records across the same columns: workflow runs, deploys, releases. It is the HTML table with styles and a few states on top, so screen readers move through it by row and by column and hear each cell's header on the way.
The Table renders every row you give it and has no paging of its own. Split long data into pages with Pagination, as two of the examples below do.
Similar#
- VirtualList keeps thousands of rows in one scrolling box by rendering only those in view. It is a list: no columns, no headers.
- Tree is for rows inside rows, opened and closed with the arrow keys.
- A table in Markdown or MDX content takes its look from Prose; Table is for data you render yourself.
Sorting on the client#
useTableSort(rows, { getValue }) returns sortedRows and getSortProps(column) to spread on a TableColumn. getValue gives a row's value in a column: numbers and dates compare as such, text by the locale (locale, else the provider's) with digits read as numbers, so v1.10.0 sorts after v1.9.2. Empty values go last in both directions. defaultSort sets the order before the first click.
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, useTableSort } from "cennge-ui";
interface Deploy {
repository: string;
version: string;
started: Date;
/** Null while the deploy is still running. */
seconds: number | null;
}
const deploys: Deploy[] = [
{ repository: "payload-cache", version: "v1.10.0", started: new Date("2026-11-06T09:12Z"), seconds: 84 },
{ repository: "cennge-ui", version: "v0.3.1", started: new Date("2026-11-01T08:40Z"), seconds: 131 },
{ repository: "ai-niche-scout", version: "v2.4.0", started: new Date("2026-11-07T16:05Z"), seconds: null },
{ repository: "payload-cache", version: "v1.9.2", started: new Date("2026-10-29T13:27Z"), seconds: 79 },
{ repository: "spredo-crypto-screener", version: "v0.9.0", started: new Date("2026-11-04T11:50Z"), seconds: 203 },
];
type Column = keyof Deploy;
const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" });
export default function DeploysTable() {
const { sortedRows, getSortProps } = useTableSort(deploys, {
getValue: (deploy, column: Column) => deploy[column],
defaultSort: { column: "started", direction: "descending" },
});
return (
<Table aria-label="Deploys">
<TableHeader>
<TableColumn {...getSortProps("repository")}>Repository</TableColumn>
<TableColumn {...getSortProps("version")}>Version</TableColumn>
<TableColumn {...getSortProps("started")}>Started, UTC</TableColumn>
<TableColumn {...getSortProps("seconds")} align="end">
Took
</TableColumn>
</TableHeader>
<TableBody>
{sortedRows.map((deploy) => (
<TableRow key={deploy.repository + deploy.version} className="whitespace-nowrap">
<TableCell className="font-medium">{deploy.repository}</TableCell>
<TableCell className="font-mono text-[13px]">{deploy.version}</TableCell>
<TableCell className="tabular-nums">{date.format(deploy.started)}</TableCell>
<TableCell align="end" className="tabular-nums">
{deploy.seconds === null ? "Running" : `${deploy.seconds} s`}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Selection across pages#
useTableSelection(keys) takes the keys of the rows on screen. The header checkbox reads allSelected and someSelected for those rows only, while selected, a Set, keeps rows ticked on other pages. isSelected on a TableRow only tints it; the checkbox in the row is what screen readers hear.
10 preview deployments
import { useState } from "react";
import {
Button,
Checkbox,
Pagination,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
useTableSelection,
} from "cennge-ui";
interface Preview {
id: string;
repository: string;
branch: string;
}
const initialPreviews: Preview[] = [
{ id: "p1", repository: "cennge-ui", branch: "docs/table" },
{ id: "p2", repository: "cennge-ui", branch: "feat/tree-lazy" },
{ id: "p3", repository: "payload-cache", branch: "fix/ttl" },
{ id: "p4", repository: "payload-cache", branch: "feat/redis-adapter" },
{ id: "p5", repository: "ai-niche-scout", branch: "feat/keyword-score" },
{ id: "p6", repository: "ai-niche-scout", branch: "chore/deps" },
{ id: "p7", repository: "spredo-crypto-screener", branch: "feat/funding-rates" },
{ id: "p8", repository: "spredo-crypto-screener", branch: "fix/ws-reconnect" },
{ id: "p9", repository: "playwright-msi-scraper", branch: "feat/retries" },
{ id: "p10", repository: "playwright-msi-scraper", branch: "chore/playwright-update" },
];
const pageSize = 4;
export default function PreviewCleanup() {
const [previews, setPreviews] = useState(initialPreviews);
const [page, setPage] = useState(1);
const pages = Math.max(1, Math.ceil(previews.length / pageSize));
const current = Math.min(page, pages);
const onScreen = previews.slice((current - 1) * pageSize, current * pageSize);
// "All" means the rows on this page; the selection keeps rows ticked on other pages.
const selection = useTableSelection(onScreen.map((preview) => preview.id));
const count = selection.selected.size;
const remove = () => {
setPreviews((list) => list.filter((preview) => !selection.isSelected(preview.id)));
selection.clear();
};
return (
<div className="flex w-full flex-col gap-3">
<div className="flex min-h-8 items-center justify-between gap-3">
<p className="text-sm text-muted" aria-live="polite">
{count ? `${count} selected across pages` : `${previews.length} preview deployments`}
</p>
<Button size="sm" variant="flat" color="danger" isDisabled={!count} onClick={remove}>
Delete
</Button>
</div>
<Table aria-label="Preview deployments">
<TableHeader>
<TableColumn className="w-10">
<Checkbox
aria-label="Select all on this page"
isSelected={selection.allSelected}
isIndeterminate={selection.someSelected}
onValueChange={selection.toggleAll}
/>
</TableColumn>
<TableColumn>Branch</TableColumn>
<TableColumn>Repository</TableColumn>
</TableHeader>
<TableBody emptyContent="No preview deployments left">
{onScreen.map((preview) => (
<TableRow key={preview.id} isSelected={selection.isSelected(preview.id)} className="whitespace-nowrap">
<TableCell>
<Checkbox
aria-label={`Select ${preview.branch}`}
isSelected={selection.isSelected(preview.id)}
onValueChange={() => selection.toggle(preview.id)}
/>
</TableCell>
<TableCell className="font-mono text-[13px]">{preview.branch}</TableCell>
<TableCell className="text-muted">{preview.repository}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination aria-label="Preview pages" total={pages} page={current} onPageChange={setPage} className="self-center" />
</div>
);
}Loading and empty#
isLoading dims the rows already shown and sets aria-busy on the table. With no rows yet, the body shows loadingContent, a spinner by default; with no rows and no loading, emptyContent. Both span every column. Choose Failed to go through both, and Refresh to dim the rows in place.
import { useRef, useState } from "react";
import {
Badge,
Button,
EmptyState,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
ToggleGroup,
ToggleGroupItem,
} from "cennge-ui";
interface Run {
job: string;
passed: boolean;
}
const runs: Run[] = [
{ job: "Typecheck, test, build", passed: true },
{ job: "Playwright · chromium", passed: true },
{ job: "Playwright · firefox", passed: true },
{ job: "Playwright · webkit", passed: true },
];
type Filter = "all" | "failed";
// Stands in for the request.
const fetchRuns = (filter: Filter) =>
new Promise<Run[]>((resolve) => setTimeout(() => resolve(filter === "failed" ? runs.filter((run) => !run.passed) : runs), 900));
export default function RunsWithStates() {
const [filter, setFilter] = useState<Filter>("all");
const [rows, setRows] = useState(runs);
const [isLoading, setLoading] = useState(false);
const request = useRef(0);
const load = async (next: Filter, keepRows: boolean) => {
const id = ++request.current;
setFilter(next);
setLoading(true);
if (!keepRows) setRows([]);
const result = await fetchRuns(next);
if (id !== request.current) return;
setRows(result);
setLoading(false);
};
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<ToggleGroup aria-label="Show" size="sm" value={filter} onValueChange={(value) => value && load(value as Filter, false)}>
<ToggleGroupItem value="all">All jobs</ToggleGroupItem>
<ToggleGroupItem value="failed">Failed</ToggleGroupItem>
</ToggleGroup>
<Button size="sm" variant="flat" isDisabled={isLoading} onClick={() => load(filter, true)}>
Refresh
</Button>
</div>
<Table aria-label="Jobs of cennge-ui run 214" isLoading={isLoading}>
<TableHeader>
<TableColumn>Job</TableColumn>
<TableColumn align="end">Result</TableColumn>
</TableHeader>
<TableBody
emptyContent={<EmptyState size="sm" title="No failed jobs" description="Every job in this run passed." />}
>
{rows.map((run) => (
<TableRow key={run.job}>
<TableCell>{run.job}</TableCell>
<TableCell align="end">
<Badge size="sm" color={run.passed ? "success" : "danger"}>
{run.passed ? "Passed" : "Failed"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}A fixed height with the header in view#
Give the wrapper a height through classNames.wrapper and set isHeaderSticky: the rows scroll under the header, and its sort buttons stay in reach. Click Took twice to put the slowest job first. size="sm" makes the rows compact and isStriped tints every other one.
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, useTableSort } from "cennge-ui";
const jobNames = [
"Typecheck, test, build",
"React 17",
"React 18",
"React 19",
"Next.js · server rendering and hydration",
"Playwright · chromium",
"Playwright · firefox",
"Playwright · webkit",
];
interface Job {
id: string;
run: number;
name: string;
branch: string;
seconds: number;
}
const jobs: Job[] = Array.from({ length: 4 }, (_, run) =>
jobNames.map((name, index) => ({
id: `${214 - run}-${index}`,
run: 214 - run,
name,
branch: run % 2 ? "docs/table" : "main",
seconds: 40 + ((run * 7 + index * 53) % 260),
})),
).flat();
const duration = (seconds: number) => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`);
type Column = "run" | "name" | "seconds";
export default function CiJobs() {
const { sortedRows, getSortProps } = useTableSort(jobs, { getValue: (job, column: Column) => job[column] });
return (
<Table aria-label="CI jobs" size="sm" isStriped isHeaderSticky classNames={{ wrapper: "max-h-72" }}>
<TableHeader>
<TableColumn {...getSortProps("run")}>Run</TableColumn>
<TableColumn {...getSortProps("name")}>Job</TableColumn>
<TableColumn>Branch</TableColumn>
<TableColumn {...getSortProps("seconds")} align="end">
Took
</TableColumn>
</TableHeader>
<TableBody>
{sortedRows.map((job) => (
<TableRow key={job.id} className="whitespace-nowrap">
<TableCell className="tabular-nums">#{job.run}</TableCell>
<TableCell>{job.name}</TableCell>
<TableCell className="font-mono">{job.branch}</TableCell>
<TableCell align="end" className="tabular-nums">
{duration(job.seconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}With TanStack Table#
TanStack Table owns the sorting and the paging; cennge-ui draws the table. TableColumn reads column.getIsSorted(), which is "asc", "desc" or false, as it is, and getToggleSortingHandler() goes straight to onSort, so Shift+click adds a second sort. The order of clicks is TanStack's: text starts ascending, numbers descending, and a third click clears the sort.
import { useState } from "react";
import {
createColumnHelper,
createPaginatedRowModel,
createSortedRowModel,
rowPaginationFeature,
rowSortingFeature,
tableFeatures,
useTable,
type PaginationState,
type SortingState,
} from "@tanstack/react-table";
import { Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "cennge-ui";
interface Run {
repository: string;
number: number;
seconds: number;
}
const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "spredo-crypto-screener", "playwright-msi-scraper"];
const data: Run[] = Array.from({ length: 24 }, (_, index) => ({
repository: repositories[index % repositories.length]!,
number: 200 - index,
seconds: 45 + ((index * 97) % 380),
}));
const duration = (seconds: number) => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`);
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
});
const helper = createColumnHelper<typeof features, Run>();
const columns = helper.columns([
helper.accessor("repository", {
header: "Repository",
sortFn: (a, b, id) => collator.compare(String(a.getValue(id)), String(b.getValue(id))),
}),
helper.accessor("number", {
header: "Run",
cell: (cell) => `#${cell.getValue()}`,
sortFn: (a, b, id) => Number(a.getValue(id)) - Number(b.getValue(id)),
}),
helper.accessor("seconds", {
header: "Took",
cell: (cell) => duration(cell.getValue()),
sortFn: (a, b, id) => Number(a.getValue(id)) - Number(b.getValue(id)),
}),
]);
const numeric = new Set(["number", "seconds"]);
export default function RunsWithTanStack() {
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 6 });
const table = useTable({
features,
columns,
data,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
});
const headers = table.getHeaderGroups()[0]?.headers ?? [];
return (
<div className="flex w-full flex-col gap-3">
<Table aria-label="Recent workflow runs">
<TableHeader>
{headers.map((header) => (
<TableColumn
key={header.id}
onSort={header.column.getToggleSortingHandler()}
sortDirection={header.column.getIsSorted()}
align={numeric.has(header.column.id) ? "end" : "start"}
>
<table.FlexRender header={header} />
</TableColumn>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="whitespace-nowrap">
{row.getAllCells().map((cell) => (
<TableCell key={cell.id} align={numeric.has(cell.column.id) ? "end" : "start"} className="tabular-nums">
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<Pagination
aria-label="Recent workflow run pages"
total={table.getPageCount()}
page={pagination.pageIndex + 1}
onPageChange={(page) => table.setPageIndex(page - 1)}
className="self-center"
/>
</div>
);
}Accessibility#
- Every header cell is a
<th scope="col">. Name the table witharia-labeloraria-labelledby. - A sortable header holds a real
<button>, and its<th>carriesaria-sort:ascending,descending, ornonewhile it is not the sorted column. The arrow beside the title is hidden from screen readers. - A table wider than its box becomes a
regionwithtabIndex={0}, named by the table's own label, so the keyboard can scroll it. A table that fits stays a plain table, with no extra Tab stop. - A table that scrolls only up and down, in a fixed height, does not become a region. Keep something focusable in it, such as sortable headers: from there the arrows scroll it.
isLoadingsetsaria-busy="true"on the table.
Styling#
className and classNames.wrapper go to the scrolling box around the table: its border, background, corners and height. classNames.table goes to the <table> itself. Each part (TableColumn, TableRow, TableCell) takes its own className, e.g. w-10 on the checkbox column or tabular-nums on numbers.
Props#
Table#
Also takes every attribute of <table>.
TableColumn#
Also takes every attribute of <tablecell>, except align, which mean something else here.
TableBody#
Also takes every attribute of <tablesection>.
TableRow#
Also takes every attribute of <tablerow>.
TableCell#
Also takes every attribute of <tablecell>, except align, which mean something else here.