Table
Composable table primitives — sortable headers, selectable rows, wrapping cells, plus TanStack-free pagination, column-visibility and state hooks.
Usage
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableRow,
TableHead,
TableCell,
TableSelectCell,
TableActionsCell,
TableSettingsCell,
TableCaption,
TablePagination,
TableViewOptions,
useSortState,
} from '@acronis-platform/ui-react';Table is a compound component over native table elements. TableHeader holds
column-header rows of TableHead; TableBody holds data rows of TableCell;
TableFooter and TableCaption are optional. TableHead can be sortable
(renders a sort button + aria-sort), TableRow takes a selected flag, and
both TableHead and TableCell take wrap for multi-line content. Three
fixed-width structural cells complete the design's anatomy: TableSelectCell
(the 32px row-selection column), TableActionsCell (the 48px trailing
row-actions column) and TableSettingsCell (the 48px trailing header column for
a column-settings trigger). Themed by the --ui-table-* token tier — every
interaction state resolves to its own token, and per the design a sortable
header tints the whole cell and owns the focus ring. Other cell content — tags,
links, status dots, numbers — is your own composition.
The primitives hold no sorting or selection logic — you own the state and
drive the props. Two companion parts (TablePagination, TableViewOptions) and
two headless hooks (useSortState, useTableUrlState) ship alongside them, all
free of any @tanstack/react-table dependency, so a plain Table can sort,
paginate and hide columns without pulling in the grid. When you want that logic
owned for you — plus filtering, row selection, column resizing, pinned columns
and server-driven data — use DataTable, which is
built on these same primitives.
Parts
| Export | Purpose |
|---|---|
Table | The <table>, inside a horizontally scrollable container. |
TableHeader | <thead> — holds column-header rows. |
TableBody | <tbody> — holds data rows. |
TableFooter | <tfoot> — summary rows, with a top divider. |
TableRow | <tr>. selected applies the active row token + data-state="selected". |
TableHead | <th>. sortable / sortDirection / onSort render the sort button; wrap as below. |
TableCell | <td>. wrap lets the content run onto multiple lines. |
TableSelectCell | 32px selection column holding a Checkbox. header renders the select-all <th>. |
TableActionsCell | 48px trailing <td> for a row's overflow trigger; tints on hover/press. |
TableSettingsCell | 48px trailing <th> for a column-settings trigger; tints on hover/press. |
TableCaption | <caption> — muted, below the table. |
TablePagination | Controlled pagination bar: row summary, rows-per-page select, first/prev/next/last. |
TableViewOptions | Show/hide-columns dropdown driven by a plain { id, label, hidden }[]. |
Examples
Basic table
A basic table with a footer:
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">INV001</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableFooter>
</Table>Sortable headers
sortable renders the label inside a real <button> with a trailing sort icon
(↑ ascending / ↓ descending in the active token, ↕ when unsorted) and sets
aria-sort on the <th>. The consumer owns the sorting logic and updates
sortDirection:
const [dir, setDir] = useState<'asc' | 'desc' | false>(false);
<TableHead
sortable
sortDirection={dir}
onSort={() => setDir(dir === 'asc' ? 'desc' : 'asc')}
>
Name
</TableHead>;useSortState does that bookkeeping for you — client-side, single-column, no
TanStack. toggleSort cycles none → asc → desc → none, getSortDirection
returns the value sortDirection expects, and sortedData is the sorted rows
(the input array is never mutated, and equal rows keep their original order):
const { sortedData, toggleSort, getSortDirection } = useSortState({
data: workloads,
initialSort: { columnId: 'name', direction: 'asc' },
});
<TableHead
sortable
sortDirection={getSortDirection('name')}
onSort={() => toggleSort('name')}
>
Name
</TableHead>;The default comparator is alphanumeric — numbers numerically, everything else
through a locale-aware numeric string compare (so item2 sorts before item10),
with null/undefined first. Override it per column with comparators, and
point it at nested fields with getValue:
useSortState({
data: workloads,
getValue: (row, columnId) => row.attributes[columnId],
comparators: { lastBackup: (a, b) => a.lastBackupAt - b.lastBackupAt },
});Selectable rows
Mark the row selected and render the Checkbox in a TableSelectCell — the
fixed 32px selection column from the design. Pass header for the select-all
cell in TableHeader:
<TableRow selected={checked}>
<TableSelectCell>
<Checkbox
checked={checked}
onCheckedChange={setChecked}
aria-label="Select row"
/>
</TableSelectCell>
<TableCell>web-server-01</TableCell>
</TableRow>Row actions and column settings
TableActionsCell is the trailing 48px column for a row's overflow trigger;
TableSettingsCell is its header counterpart for a column-settings trigger.
Both tint on hover/press and draw the focus ring on the cell, so the trigger
inside stays a plain ButtonIcon:
<TableHeader>
<TableRow>
<TableSelectCell header>
<Checkbox aria-label="Select all rows" />
</TableSelectCell>
<TableHead sortable>Name</TableHead>
<TableSettingsCell>
<ButtonIcon aria-label="Column settings">
<CogIcon />
</ButtonIcon>
</TableSettingsCell>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableSelectCell>
<Checkbox aria-label="Select web-server-01" />
</TableSelectCell>
<TableCell>web-server-01</TableCell>
<TableActionsCell>
<ButtonIcon aria-label="Actions for web-server-01">
<EllipsisIcon />
</ButtonIcon>
</TableActionsCell>
</TableRow>
</TableBody>What the settings trigger opens is your choice — TableViewOptions with
iconOnly is the ready-made column-visibility menu, and renders exactly this
cog trigger itself.
Wrapping cells
Cells and headers are a fixed row height (--ui-table-global-cell-min-height,
40px) and stay on one line by
default. wrap drops that height and switches the cell to whitespace-normal,
so the row grows to fit multi-line content instead of truncating it:
<TableRow>
<TableCell>web-server-01</TableCell>
<TableCell wrap>{longDescription}</TableCell>
</TableRow>Pagination
TablePagination is fully controlled — it renders from plain props and never
holds page state. It shows an optional row/selection summary, a rows-per-page
Select (pageSizeOptions, default [10, 20, 30, 40, 50]), the page indicator,
and first / previous / next / last icon buttons (first and last appear from the
lg breakpoint up). Slice the rows yourself:
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const pageCount = Math.ceil(rows.length / pageSize);
const pageRows = rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
<TablePagination
pageIndex={pageIndex}
pageCount={pageCount}
pageSize={pageSize}
totalRows={rows.length}
selectedRows={selectedIds.length}
onPageIndexChange={setPageIndex}
onPageSizeChange={setPageSize}
/>;The summary reads "{selectedRows} of {totalRows} row(s) selected." when both
counts are given, "{totalRows} row(s)." with totalRows alone, and is omitted
when neither is passed. The indicator reads Page {pageIndex + 1} of {pageCount}
— or "No pages" when pageCount is 0, so an empty filtered result never
shows "Page 1 of 0".
Every string the bar renders itself is a prop, so it can be localized:
rowsPerPageLabel, firstPageLabel, previousPageLabel, nextPageLabel,
lastPageLabel, and the pageLabel(page, pageCount) / summaryLabel(selected, total) formatters:
<TablePagination
{...paginationProps}
rowsPerPageLabel="Zeilen pro Seite"
pageLabel={(page, count) => `Seite ${page} von ${count}`}
summaryLabel={(selected, total) => `${selected} von ${total} ausgewählt.`}
/>Column visibility
TableViewOptions is a "View" dropdown of checkbox items, driven by a plain
column list and an onToggle that hands you the toggled column's id. The menu
stays open across toggles. Pass iconOnly for the cog-only trigger that fits a
TableSettingsCell (its accessible name comes from triggerAriaLabel, default
'Column settings'):
const [hidden, setHidden] = useState<string[]>([]);
<TableViewOptions
columns={[
{ id: 'name', label: 'Name', hidden: hidden.includes('name') },
{ id: 'status', label: 'Status', hidden: hidden.includes('status') },
]}
onToggle={(id) =>
setHidden((current) =>
current.includes(id) ? current.filter((c) => c !== id) : [...current, id]
)
}
/>;Skip the columns you filtered out when you render TableHead/TableCell —
TableViewOptions reports the intent, it does not touch the table.
Bookmarkable URL state
useTableUrlState mirrors pagination, sorting and column filters into
namespaced (tbl_*) query params so a view is bookmarkable and restores on
load. It is router-agnostic — only window.location +
history.pushState/popstate — and its state is shaped for TanStack's
controlled setters, so it pairs with either the primitives or the grid. See
the DataTable page for the
encoding and a wiring example.
Accessibility
- The parts render native table semantics, so rows, columns and headers are
announced without extra ARIA. Give the table an accessible name with a
TableCaptionor anaria-label, and setscope="col"/scope="row"on<th>in complex tables (both pass through natively). - A
sortableTableHeadsetsaria-sort(none/ascending/descending) and puts the label in a real<button>, so it is reachable by Tab, activates with Enter / Space, and shows a--ui-focus-primaryfocus ring. - Selection checkboxes have no visible label in the cell — pass your own
aria-label("Select row" / "Select all"), and keep the checkbox's checked state in sync with the row'sselectedflag so the visual and programmatic states agree. TablePagination's controls are icon buttons with explicit labels ("Go to first page" … "Go to last page") and the rows-per-page control is a labelledSelect; buttons at the range ends carry the nativedisabledstate.TableViewOptionsis a keyboard-navigable dropdown ofrole="menuitemcheckbox"items with a visible "View" trigger label.
API Reference
TableHead
Prop
Type
TableRow
Prop
Type
TableCell
Prop
Type
TableSelectCell
Prop
Type
TableActionsCell
Prop
Type
Table, TableHeader, TableBody, TableFooter, and TableCaption accept the
standard attributes of the element they render (<table>, <thead>, <tbody>,
<tfoot>, <caption>). TableSettingsCell accepts the standard <th>
attributes.
TablePagination
Prop
Type
TableViewOptions
Prop
Type
Prop
Type
useSortState
useSortState<TData>(options): {
sortedData: TData[];
sort: { columnId: string; direction: 'asc' | 'desc' } | null;
setSort: (sort: SortState | null) => void;
toggleSort: (columnId: string) => void;
getSortDirection: (columnId: string) => 'asc' | 'desc' | false;
};| Option | Type | Purpose |
|---|---|---|
data | TData[] (required) | The rows to sort. |
initialSort | SortState | null (default null) | Column + direction to start sorted by. |
comparators | Record<string, (a: TData, b: TData) => number> | Per-column comparator overrides, keyed by column id. Ascending; the hook negates for descending. |
getValue | (row: TData, columnId: string) => unknown (default row[columnId]) | How the default comparator reads a column's value. |
useTableUrlState is documented on
the DataTable page.