← All components
data-display / stable

DataTable

Keyboard-accessible data grid. Pass data rows with stable id values and columns built with createDataTableColumnHelper; enable selectable and pageSize as needed.

LIVE PREVIEW
Loading preview…

When to use

  • Admin lists, reports and dense tabular data.
  • Sortable / paginated / selectable rows of records.
  • Numeric reports: right-align amount columns with columnAlign and pin the totals row with footer + stickyFooter.

Consider an alternative

  • Work-item lists with inline property pickers — use ListView.
  • Workflow columns — use KanbanBoard with KanbanColumn.

Import

import { DataTable } from '@revido-llc/kit';

Keyboard interaction

  • Arrow keys — move between cells
  • Enter / Space — activate the focused cell control
  • Tab — leave the grid for the next page control

Props

Props documented by the Kit. Refer to the TypeScript types for the complete interface, including inherited props.

aria-label (required)string—Accessible name for the table.
columns (required)ColumnDef<{ rowSortingFeature: TableFeature; sortedRowModel: (table: Table<any, any>) => () => RowModel<any, any>; rowPaginationFeature: TableFeature; pagina…—TanStack column defs (prefer `createDataTableColumnHelper`).
data (required)TData[]—Row data. Each row needs a stable `id`.
classNamestring—
columnAlignReadonly<Partial<Record<string, DataTableAlign>>>"start"Alignment per column id. "end" aligns the header (the sort icon leads), cells, loading skeletons and footer cells to the inline end (right in left-to-right pages, left in right-to-left ones) — use it for numbers and amounts.
emptyStateReactNode—Empty placeholder when `data` is empty.
firstColumnWidth`${number}rem` | `var(--${string})`—First data-column width, in rem or a CSS token. Defaults to 14rem when pinned.
footerreadonly DataTableFooterCell[]—A totals row in the same grid, aligned to columns. Cell spans must sum to the column count. With `stickyFooter` and `stickyFirstColumn`, the cells over the pinned columns (selection and first data column) stay in place while scrolling horizontally. One cell may span both pinned columns; a cell that spans past them is not pinned and scrolls with the rest of the row.
footerDataAttributesDataTableDataAttributes—Domain metadata on the totals row.
hidePaginationbooleanfalseOmit the built-in footer when the application owns a separate Pagination.
isRowDisabled((row: TData) => boolean)—Disable interactions on auxiliary data rows such as totals and empty placeholders.
itemLabelstring"rows"Plural noun in the range summary.
loadingbooleanfalseReplace data with matching placeholder rows while content loads.
loadingRowsnumber5Placeholder row count while loading.
manualPaginationbooleanfalseData already contains the current page; do not slice it again.
manualSortingbooleanfalsePreserve application/server row order while reporting header sort changes.
onPaginationChange((pagination: PaginationState) => void)—Atomic page/size change; adapt pageIndex + 1 to a one-based URL page.
onRowAction((row: TData) => void)—Activate a record through React Aria pointer/keyboard row actions.
onRowPress((row: TData, event: DataTableRowPressEvent) => void)—Row press with modifier keys and its DOM row. Prefer onRowAction when no event is needed.
onRowSelectionChange((selection: RowSelectionState) => void)—Selection change.
onSortingChange((sorting: SortingState) => void)—Human header-sort changes, using the same literal column IDs.
pageSizenumber—Page size; omit to show all rows.
pageSizeOptionsnumber[]—Optional page-size picker in the shared Pagination footer.
paginationPaginationState—Controlled TanStack pagination (zero-based pageIndex). Takes precedence over pageSize.
renderGroupHeader((row: TData, previousRow: TData) => ReactNode)—Content before a visible row; return null when it continues the previous group. Spans every column.
renderRowDetails((row: TData) => ReactNode)—Full-width content after a row; return null while collapsed. Expansion state stays with the owner.
rowDataAttributes((row: TData) => DataTableDataAttributes)—Domain metadata (data-testid, data-person, etc.) for real data rows only.
rowSelectionRowSelectionState—Controlled selection (row id → selected).
scrollClassNamestring—Layout classes for the table scroll area, for example max-h-80.
selectablebooleanfalseEnable multi-row checkbox selection (React Aria Table selection).
sortingSortingState—Controlled sorting shared with agent adapters and application toolbars.
stickyBottomRowIdstring—Pin a designated data row's cells at the bottom of the scroll area (for a total kept as a data row).
stickyFirstColumnbooleanfalsePin the first data column and any selection column during horizontal scroll.
stickyFooterbooleanfalsePin the footer (totals) row at the bottom of the scroll area. Pair with scrollClassName.
stickyHeaderbooleanfalsePin headers inside the scroll area. Set a max height with scrollClassName.
totalnumber—Total rows across all pages when manualPagination is enabled.

Related exports

createDataTableColumnHelper

Examples

Literal rows and columns

JSON-safe accessor keys and scalar rows; use a helper for application renderers.

<DataTable aria-label="People" data={[{ id: "ada", name: "Ada", role: "Engineer" }, { id: "grace", name: "Grace", role: "Lead" }]} columns={[{ accessorKey: "name", header: "Name", enableSorting: true }, { accessorKey: "role", header: "Role" }]} />

Sortable + paginated

Click a header to sort; page through rows.

const helper = createDataTableColumnHelper<Person>();
const columns = helper.columns([
  helper.accessor("name", { header: "Name", enableSorting: true }),
  helper.accessor("role", { header: "Role" }),
]);
<DataTable aria-label="Team" data={people} columns={columns} pageSize={20} />

Selectable

<DataTable selectable rowSelection={selection} onRowSelectionChange={setSelection} … />

Sticky totals, row presses and loading

Names, headers and the totals row stay visible while scrolling; the amount column is right-aligned. Row presses retain modifier keys and the row element. Loading and empty results retain headers.

<DataTable aria-label="Entries" data={entries} columns={columns} stickyHeader scrollClassName="max-h-80" stickyFirstColumn hidePagination
  columnAlign={{ amount: "end" }} footer={[{ content: "All entries" }, { content: total }]} stickyFooter
  onRowPress={(row, event) => openRecord(row.id, { trigger: event.currentTarget, newTab: event.metaKey || event.ctrlKey })} />
<DataTable aria-label="Team" data={[]} columns={columns} loading loadingRows={5} />
<DataTable aria-label="Team" data={[]} columns={columns} emptyState={<EmptyState icon={Users} title="No people found" />} />

URL-controlled rows

Manual transforms preserve server results. Update sort/order/page/pageSize in one URL replacement, retaining unrelated parameters. Inspect opens full-width details; group labels and expansion state belong to the app.

// searchParams and replaceUrl come from the consuming app's router.
const sorting = [{ id: searchParams.get("sort") ?? "name", desc: searchParams.get("order") === "desc" }];
const pagination = { pageIndex: Number(searchParams.get("page") ?? 1) - 1, pageSize: Number(searchParams.get("pageSize") ?? 25) };
function updateUrl(patch) {
  const next = new URLSearchParams(searchParams);
  for (const [key, value] of Object.entries(patch)) next.set(key, String(value));
  replaceUrl(next.toString());
}
<DataTable aria-label="People" data={serverRows} columns={columns} manualSorting manualPagination total={total}
  sorting={sorting} onSortingChange={(next) => updateUrl({ sort: next[0]?.id ?? "name", order: next[0]?.desc ? "desc" : "asc", page: 1 })}
  pagination={pagination} onPaginationChange={(next) => updateUrl({ page: next.pageIndex + 1, pageSize: next.pageSize })}
  pageSizeOptions={[10, 25, 50]} onRowAction={(row) => openRecord(row.id)}
  renderGroupHeader={(row, previous) => row.group !== previous?.group ? row.groupLabel : null}
  renderRowDetails={(row) => expandedIds.has(row.id) ? <Details record={row} /> : null} />
// Domain metadata and totals: spans match the column count.
// rowDataAttributes={(row) => ({ "data-person": row.personId })}
// footer={[{ content: "All entries", colSpan: 2 }, { content: totals.amount }]}
// footerDataAttributes={{ "data-testid": "reconciliation-total" }}
// Independent row controls: helper.display({ id: "actions", header: "Actions", cell: ({ row }) => <Button onPress={() => inspect(row.original)}>Inspect</Button> })

Related components