Skip to content

DataTable

A data-driven table for asset browsers, log views, scene outliners, and any other surface that lists structured rows. Built around a CSS grid under role="grid" so columns line up between the sticky header and individual rows — including virtualized ones. Supports sortable columns, single or multi-row selection, density, sticky-left columns, column resizing, a loading state, an empty state, and row virtualization that auto-engages above 100 rows.

Live Preview

Import

import { DataTable } from 'entangle-ui';
import type {
DataTableColumn,
DataTableSortState,
DataTableSelectionState,
} from 'entangle-ui';

Usage

Pass rows, columns, and a rowKey. The table reads each cell with the column’s accessor (string key or function) and renders it through the column’s optional cell renderer.

interface User {
id: string;
name: string;
email: string;
role: string;
}
const columns: DataTableColumn<User>[] = [
{ id: 'name', header: 'Name', accessor: 'name', sortable: true },
{ id: 'email', header: 'Email', accessor: 'email' },
{ id: 'role', header: 'Role', accessor: 'role' },
];
<DataTable<User>
rows={users}
columns={columns}
rowKey="id"
aria-label="Users"
/>;

rowKey is required and must produce a stable string for every row — it is used as the React key, the selection identifier, and the virtualization key.

Columns

A DataTableColumn<R> describes one column:

const columns: DataTableColumn<User>[] = [
{
id: 'name',
header: 'Name',
accessor: 'name', // key into the row
sortable: true,
minWidth: 200,
},
{
id: 'status',
header: 'Status',
accessor: 'status',
width: 120, // fixed track
cell: ({ value }) => <StatusBadge status={value} />,
},
{
id: 'size',
header: 'Size (MB)',
accessor: 'size',
align: 'right',
},
];
FieldPurpose
idRequired stable identifier.
headerHeader content (string or any node).
accessorkeyof R or (row) => unknown — how to read the cell value.
cellCustom renderer for the cell. Receives { value, row, rowIndex, column }.
widthFixed track width. Number → px, string → CSS dimension.
minWidthMinimum width. Used both as the grid floor and the resize floor.
maxWidthMaximum width when resizing.
sortableEnables the header click + sort cycle.
sortComparatorCustom comparator. Defaults handle string, number, boolean, Date.
alignleft / center / right. Defaults to left.
stickyPins the column to the left edge while scrolling.
disableResizeLocks this column even when resizableColumns is on.

Sorting

Set sortable: true on each sortable column. Clicking the header cycles through asc → desc → none. The default comparator handles strings (locale-aware), numbers, booleans, and Date instances; null and undefined are pushed to the end.

Sortable columns

<DataTable
rows={rows}
columns={columns}
rowKey="id"
defaultSort={{ columnId: 'name', direction: 'asc' }}
/>

Use controlled mode for server-side sorting — pass sort, react in onSortChange, and set manualSort so the table renders the rows as supplied without re-sorting them client-side:

const [sort, setSort] = useState<DataTableSortState>({
columnId: 'name',
direction: 'asc',
});
<DataTable
rows={rowsFromServer}
columns={columns}
rowKey="id"
sort={sort}
onSortChange={setSort}
manualSort
/>;

Selection

Two modes: single (radio-style, one row at a time) or multiple (checkbox column with a select-all header).

Multiple selection

const [selection, setSelection] = useState<DataTableSelectionState>([]);
<DataTable
rows={rows}
columns={columns}
rowKey="id"
selectionMode="multiple"
selection={selection}
onSelectionChange={setSelection}
/>;

Single-mode selection adds no checkbox column — instead, clicking a row toggles its selection and Enter activates it:

Single selection

Use isRowSelectable={(row) => row.status !== 'archived'} to disable selection per row. The select-all header reflects the resulting subset.

defaultSelection enables uncontrolled mode; omit selection / onSelectionChange and the table tracks state internally.

Range selection

In multiple mode the table tracks the last clicked row as a selection anchor. Hold Shift while clicking another row’s checkbox to toggle every row between the anchor and the target — selecting them when the target was unselected, deselecting them when it was already selected. Disabled rows (isRowSelectable returns false) are skipped over.

Shift + Space does the same with the keyboard once a row is focused. No prop to enable it — range selection is on by default for multi-select tables.

Density

Three densities cover most table surfaces.

Densities

DensityRow heightUse for
comfortable40 pxDefault. Wide layouts, mixed content.
compact32 pxDense lists, secondary tables.
dense24 pxLogs, telemetry, anything with > 100 rows.
<DataTable density="compact" rows={rows} columns={columns} rowKey="id" />

Sticky columns

Pin a column to the left edge by setting sticky: true on its definition. The sticky shadow appears as the user scrolls horizontally.

Sticky first column

const columns: DataTableColumn<User>[] = [
{ id: 'name', header: 'Name', accessor: 'name', sticky: true, width: 200 },
...
];

The header is sticky by default (stickyHeader: true).

Column resizing

Set resizableColumns to enable a drag handle on the right edge of each header cell. Per-column minWidth / maxWidth clamp the drag; disableResize: true opts an individual column out.

Resizable columns

<DataTable
rows={rows}
columns={columns}
rowKey="id"
resizableColumns
onColumnResize={(columnId, width) => persistWidth(columnId, width)}
/>

Loading

Render skeleton rows while the consumer is fetching.

Loading

<DataTable
rows={[]}
columns={columns}
rowKey="id"
loading
loadingRowCount={6}
/>

Empty state

When rows.length === 0 and loading is false, the table renders the emptyState slot — pass a string or any node.

Empty state

<DataTable
rows={[]}
columns={columns}
rowKey="id"
emptyState="No assets yet — drag a file to import."
/>

Virtualization

virtualized="auto" (the default) enables row virtualization once the data set exceeds virtualizationThreshold (100 rows by default). The grid layout is unchanged, so column widths and the sticky header line up exactly the same as in non-virtualized mode.

Virtualized (2,000 rows)

<DataTable rows={largeList} columns={columns} rowKey="id" height={480} />

Force virtualization with virtualized={true}, or disable it entirely (useful for tests or print views) with virtualized={false}. Tune the kept-mounted off-screen rows with overscan (default 8).

Virtualization requires a fixed scroll-container height — pass a number (px) or any CSS dimension.

Custom row renderer

Wrap the default cells in your own row element with renderRow. The render function receives { row, rowIndex, selected } and the pre-built cells node — return whatever wrapper you need.

<DataTable
rows={rows}
columns={columns}
rowKey="id"
renderRow={({ row, selected }, cells) => (
<Link href={`/assets/${row.id}`} className={selected ? 'is-selected' : ''}>
{cells}
</Link>
)}
/>

Keyboard navigation

The table is a single focusable region (tabIndex={0}) with a WAI-ARIA grid layout. Once focused:

KeyAction
ArrowUp / ArrowDownMove row focus by one.
Home / EndJump to the first / last row.
PageUp / PageDownJump 10 rows at a time.
SpaceToggle the focused row (multi-select only).
EnterActivate the focused row (and toggle in single-select).

Sortable headers are individually focusable and respond to Enter / Space.

Accessibility

  • Root element renders as role="grid" with aria-rowcount, aria-colcount, and aria-multiselectable set accordingly.
  • Each header cell is role="columnheader"; sortable headers expose aria-sort (ascending / descending / none).
  • Body rows are role="row" with aria-rowindex; cells are role="gridcell".
  • Selected rows have aria-selected="true" when selectionMode is on.
  • Skeleton rows during loading are aria-hidden; the surrounding container is responsible for announcing loading state.
  • Resize handles are role="separator" with aria-orientation="vertical" and a per-column aria-label.

API Reference

<DataTable>

Prop Type Default Description
rows readonly R[] Row data.
columns readonly DataTableColumn<R>[] Column definitions.
rowKey keyof R | ((row: R, rowIndex: number) => string) How to derive a stable key for each row. Required for selection and virtualization stability.
density 'comfortable' | 'compact' | 'dense' 'comfortable' Visual density. Affects row height and cell padding.
stickyHeader boolean true Whether the header sticks to the top while scrolling.
sort DataTableSortState | null Controlled sort state. `{ columnId, direction }`.
defaultSort DataTableSortState | null Default sort state for uncontrolled mode.
onSortChange (state: DataTableSortState) => void Called when the user changes the sort.
manualSort boolean false When true, the consumer fully owns sorting — the table renders rows as-is. Useful for server-side sorting.
selectionMode 'single' | 'multiple' | false false Selection behavior.
selection string[] Controlled selection (array of row keys).
defaultSelection string[] Default selection for uncontrolled mode.
onSelectionChange (next: string[]) => void Called when selection changes.
isRowSelectable (row: R, rowIndex: number) => boolean Disable selection of a specific row.
onRowClick (row: R, rowIndex: number, event: MouseEvent) => void Row click handler. Independent of selection.
onRowActivate (row: R, rowIndex: number) => void Row activation (Enter key or double click).
renderRow (info: { row; rowIndex; selected }, cells: ReactNode) => ReactNode Custom row renderer. Receives the default cells; the consumer wraps them.
emptyState ReactNode Empty state slot or text. Rendered when `rows.length === 0`.
loading boolean false Loading flag — renders skeleton rows.
loadingRowCount number 5 Number of skeleton rows shown while loading.
virtualized boolean | 'auto' 'auto' Virtualization mode. `'auto'` enables it once `rows.length` exceeds `virtualizationThreshold`.
virtualizationThreshold number 100 Threshold above which `'auto'` mode flips on.
estimatedRowHeight number Estimated row height in px. Defaults to a density-dependent value.
overscan number 8 Number of off-screen rows kept mounted.
height number | string 480 Total scroll container height. Required when virtualized.
maxHeight number | string Maximum height before scroll engages (non-virtualized mode).
resizableColumns boolean false Enable drag-to-resize on the right edge of each header.
onColumnResize (columnId: string, width: number) => void Called when a column is resized.
aria-label string Optional aria-label for the grid.
aria-labelledby string Optional aria-labelledby for the grid.

DataTableColumn<R>

Prop Type Default Description
id string Stable column identifier. Required.
header ReactNode Header content (string or any node).
accessor keyof R | ((row: R, rowIndex: number) => unknown) How to read the cell value from a row. When omitted, `id` is used as a key into the row.
cell (info: { value; row; rowIndex; column }) => ReactNode Cell renderer. When omitted, the value is stringified (empty for null/undefined).
width number | string Fixed column width. Number → px, string → CSS dimension.
minWidth number Minimum column width in px (used for resizing and as the grid floor).
maxWidth number Maximum column width in px (used for resizing).
sortable boolean When true, header click toggles sort on this column.
sortComparator (a, b, rowA, rowB) => number Custom comparator. Default handles strings, numbers, booleans, Date; null/undefined ordered last.
align 'left' | 'center' | 'right' 'left' Cell horizontal alignment.
sticky boolean When true, this column sticks to the left edge while scrolling.
ariaHidden boolean When true, the column header is not focusable for keyboard users.
disableResize boolean When true, this column cannot be resized even if `resizableColumns` is on.