Acronis UIKit
Components

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

ExportPurpose
TableThe <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.
TableSelectCell32px selection column holding a Checkbox. header renders the select-all <th>.
TableActionsCell48px trailing <td> for a row's overflow trigger; tints on hover/press.
TableSettingsCell48px trailing <th> for a column-settings trigger; tints on hover/press.
TableCaption<caption> — muted, below the table.
TablePaginationControlled pagination bar: row summary, rows-per-page select, first/prev/next/last.
TableViewOptionsShow/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/TableCellTableViewOptions 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 TableCaption or an aria-label, and set scope="col" / scope="row" on <th> in complex tables (both pass through natively).
  • A sortable TableHead sets aria-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-primary focus 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's selected flag 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 labelled Select; buttons at the range ends carry the native disabled state.
  • TableViewOptions is a keyboard-navigable dropdown of role="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;
};
OptionTypePurpose
dataTData[] (required)The rows to sort.
initialSortSortState | null (default null)Column + direction to start sorted by.
comparatorsRecord<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.

Edit on GitHub

On this page