Table · Pagination Composition
A table, not a data grid: it renders rows and sorts by a column. No virtualisation, no column resizing, no inline editing — those are product features, and a design system that grows them becomes a framework nobody can leave.
Usage
| Status | |||||
|---|---|---|---|---|---|
| INV-1045 | Citra Dewi | pending | 2026-08-09 | Rp 2.300.000 | |
| INV-1044 | Budi Santoso | paid | 2026-08-06 | Rp 640.000 | |
| INV-1042 | Rio Hakim | pending | 2026-08-03 | Rp 1.120.000 | |
| INV-1041 | Ada Putri | paid | 2026-08-01 | Rp 4.250.000 | |
| INV-1043 | Sarah Chen | overdue | 2026-07-18 | Rp 9.800.000 |
const [sort, setSort] = useState<SortState | null>({ column: 'issued', direction: 'desc' });
<Table label="Invoices" columns={columns} rows={rows} rowKey={(row) => row.id} sort={sort} onSortChange={setSort} empty={<Empty title="No invoices yet" />}/>
<Pagination page={page} pageSize={20} total={137} onPageChange={setPage} />Sorting has three states
Clicking a new column sorts ascending, clicking again flips it, and clicking a third time clears the sort. That third state matters: "put it back the way the server gave it to me" is something people look for, and two-state sorting hides it.
The state lives on the header as aria-sort, not only in the icon — a screen reader announces the
sort without seeing the arrow.
Do
Keep sorting controlled: the table reports what was clicked and your data layer decides what that means.
Don't
Sort inside the table over a paginated set — you would only be sorting the page in view.
Pagination
<Pagination page={page} pageSize={10} total={300} onPageChange={setPage} />The page list always keeps the first and last page reachable, and a single skipped page is spelled
out rather than hidden behind an ellipsis that costs the same width. An empty set reads 0–0 of 0
rather than 1–0.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
| columns | Array<Column<Row>> | — | key, header, cell(row), align, width, sortable, secondary. |
| rows / rowKey | Row[] / (row) => string | — | A stable key per row; index keys break every time the sort changes. |
| label | string | — | Required accessible name for the table. |
| sort / onSortChange | SortState | null | — | Controlled. nextSort() is exported if you drive it yourself. |
| onRowClick | (row) => void | — | Makes rows clickable; keep a real control in the row too, for keyboards. |
| empty | ReactNode | — | Replaces the table entirely when there are no rows — an Empty fits here. |
| Column.secondary | boolean | — | Hides the column below 720px, for what a phone can live without. |
| Column.truncate | boolean | — | Clips overflowing text with an ellipsis instead of letting one long value widen the column. |