DataGrid
An interactive data grid on TanStack Table — the full APG grid keyboard, sorting, a filter bar, row selection with batch actions, column and density menus, column menus, resizing, pinning, row virtualization, inline editing, pagination and CSV export, built from the real Table parts.
pnpm add @tessinaui/uiWhen to use
- Data people work in: find a row, compare, select several, act on them, take the data elsewhere. It is keyboard-operable as a grid, with one Tab stop and arrow keys between cells, which is why it is a separate component from Table.
- A sortable, filterable, selectable list of records with bulk actions (
selectable,batchActions) or CSV export (showExport). - Editing one cell at a time. Mark columns
editableand write the change back inonCellEdit. - Thousands of rows in one scroll. Use
virtualize, orpagination, which 15 of the 30 surveyed products chose. - A named, stable grid. Give every grid a
labelorcaption, and a stablegetRowIdwhen rows can be re-ordered, re-fetched or selected.
When not to use
- For a static or lightly interactive table: a comparison, a crosstab, a summary, a report. Use Table, which keeps the natural tab order and has no grid keys.
- To edit a whole record, or fields that depend on each other. Open a form or a drawer from a row action instead.
- For operators, AND/OR groups or a "+ Add filter" builder. Build that above the grid and feed the controlled
columnFilters. - For pivots, formulas or spreadsheet parity. Not this component, by design.
- For controls inside headers or whole-row clicks. Do not put filters in header cells, nest a control inside a sortable header, or make whole rows clickable. Put a link in the row's first cell instead; a lone link takes the cell's focus directly.
- For page layout. A grid holds data, not structure.
Installation
pnpm add @tessinaui/uiDataGrid runs on TanStack Table v8 and
TanStack Virtual v3, which install with it,
pinned to exactly 8.21.3 and 3.14.13. The registry item names the pins too,
so a registry install lands the versions the grid was written against — not
latest, which for Table is a different major.
Usage
import { DataGridRoot, type DataGridColumn } from "@tessinaui/ui";
type Member = { id: string; name: string; role: string; seats: number };
const columns: DataGridColumn<Member>[] = [
{ accessor: "name", header: "Name" },
{
accessor: "role",
header: "Role",
filter: { type: "select", options: [{ value: "Admin", label: "Admin" }, { value: "Member", label: "Member" }] },
},
{ accessor: "seats", header: "Seats", numeric: true },
];
<DataGridRoot
data={members}
columns={columns}
getRowId={(member) => member.id}
label="Team members"
selectable
showFilterBar
showColumnsMenu
showExport
/>A column's accessor types its cell renderer: { accessor: "seats", cell: ({ value }) => … }
hands cell a number. header is plain text on purpose — it also labels the
columns menu, the CSV header, the filter and the sort announcement.
Narrow screens
The grid scrolls inside its frame and the page never does: data tables are the
two-dimensional content WCAG 1.4.10 exempts, and none of the 30 phone data
grids surveyed collapsed rows into cards. The toolbar wraps. Hide columns
(columnVisibility) rather than restyling the table.
The pager follows the viewport, not the column: below 640px it shows a page counter, and wider it shows the full number row. On a desktop, give a paginated grid a column of at least 320px — in a narrower one the pager runs past its edge.
Examples
Default
Search, the columns menu, CSV export and row selection over a team list; the first column is the row header.
Sorting
The header's sort button cycles ascending, descending and off, and says so in a
polite live region. One column sorts at a time. sortFn orders a column by
meaning; enableSorting: false leaves one plain.
Filter bar
Search matches every text and number column; a column with filter gets a
Select in the bar — never in its header. The count ("2 of 7 rows") and Clear
filters follow what is applied, and the count is announced when a filter
settles.
Selection and batch actions
The checkbox, Space, Shift+Space from anywhere in the row, or Ctrl/Cmd+A. While
rows are selected the batch bar replaces the toolbar and hands batchActions
the selected rows. Filtering drops the rows it hides from the selection, so a
bulk action never reaches a row nobody can see. The actions cell holds two
buttons: the cell takes focus, Enter reaches them, Escape comes back.
Column visibility
enableHiding: false keeps a column shown and locked in the menu; the last
visible column cannot be hidden.
Density
Density is the grid's size. The menu offers Compact (sm), Default (md) and
Comfortable (lg); rows, header and toolbar follow together.
CSV export
A UTF-8 CSV (with the byte-order mark spreadsheet apps need) of what the search shows —
or all rows, from the menu that appears while a filter hides some. A selection
exports from the batch bar. Text a spreadsheet would run as a formula — opening
=, +, -, @, a tab or a carriage return — gets a leading ', as OWASP
advises; numbers are left alone. exportValue sets a column's value in the file.
Pagination
Client-side pages with the house Pagination parts. With rows off the page the
grid gives assistive tech the full count (aria-rowcount) and each row's place
in it (aria-rowindex); the header checkbox selects the current page.
Controlled state
Every piece of state is a controlled/uncontrolled pair. useDataGrid() hands a
toolbar slot, a batch action or a cell renderer the grid's TanStack instance,
its density, strings and CSV export.
Empty and loading
No data at all takes your empty; filters that hide every row show No results
with Clear filters; loading draws skeleton rows under the live header and
marks the grid busy. The toolbar and header stay in place for all three.
Load error
error replaces the rows, and its title is announced once — again if a retry
fails too.
Skeleton
DataGridSkeleton stands in before the columns exist. It is the real grid in
its loading state, inert and hidden, so its header, rows and touch floors are
the grid's own; it is announced once.
On color
Inside a coloured Surface the framed grid paints its own page panel, so the
rows read the page; the toolbar sits on the plate and takes its inks. Nothing
sets tone.
RTL
dir="rtl" mirrors the grid, flips ArrowLeft/ArrowRight and picks the Arabic
strings, counts included. An inherited direction mirrors the layout but keeps
the strings as they are — a direction names no language.
Column menus and resizing
resizable puts a drag strip on each header's end edge and turns on
columnMenus: the header itself becomes a menu button — sort, pin, hide, and
the column's width. Widen and Narrow keep the menu open, so every tap is
one step — the way to size a column without dragging (WCAG 2.5.7). Resize with
keyboard hands focus to the strip as a splitter: Left/Right move it (mirrored
under RTL), Shift moves further, Home/End go to minWidth/maxWidth, Enter or
Escape come back. A resizable, pinned, virtualized or editable grid lays out
from its column widths — a number width is exact there — and the last column
takes whatever the frame has left.
Pinned columns
pinned: "start" — or Pin to start in a column menu — keeps a column at the
start edge while the grid scrolls sideways (the right edge under RTL). The
selection column pins with it. Pin the column that names the row, so each row
keeps its name in view.
Virtualized rows
virtualize renders only the rows in view — ten thousand here. The rows stay in
the table between spacer rows, so borders, stripes, the sticky header and
pinned cells behave as in a full table; assistive tech gets the full count
(aria-rowcount) and each row's place (aria-rowindex). The row with focus
stays mounted while you scroll away, so Tab comes back to it. A virtualized grid
scrolls in its own frame: maxHeight defaults to min(30rem, 70dvh) and the
header sticks. Keep rows one line tall; pagination turns it off.
Inline editing
A column with editable — true for text, { type: "number" }, or
{ type: "select", options } — opens an editor on Enter, F2 or a double-click.
Enter commits and moves down the column, Tab along the row; Escape cancels; a
click elsewhere commits. validate keeps the editor open with its message. The
grid shows data, so onCellEdit has to write the change back — an unchanged
value reports nothing.
Everything at once
Two thousand virtualized rows with a pinned key column, resizable columns, editable title, priority, status and estimate, filters, density, export and a batch action.
API Reference
DataGridRoot
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | — | The rows |
columns | DataGridColumn<TData>[] | — | See DataGridColumn below |
getRowId | (row, index) => string | row index | A stable id — selection survives sorting, filtering and refetches only with one |
label | string | — | The grid's accessible name. Required unless caption names it (dev-warn) |
caption | ReactNode | — | A visible caption; names the grid |
size / defaultSize / onSizeChange | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density — Table's cell padding and data-display type ladder |
variant | "lined" | "striped" | "bordered" | "borderless" | "lined" | Frame treatment, as Table |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Frame radius; publishes --table-item-radius |
dir | "ltr" | "rtl" | inherited | Stamped only when set. An explicit "rtl" also picks the Arabic strings |
locale | string | "ar" under dir="rtl", else "en-US" | Plural rules for the counts |
strings | Partial<DataGridStrings> | — | Per-entry overrides of the strings table |
showFilterBar | boolean | false | Search, column filters, the result count and Clear filters |
showColumnsMenu | boolean | false | Column visibility menu |
showDensityMenu | boolean | false | Compact / Default / Comfortable |
showExport | boolean | DataGridCsvOptions | false | CSV export; options apply to every export |
toolbarStart / toolbarEnd | ReactNode | — | Your controls in the toolbar; useDataGrid() works inside |
selectable | boolean | false | Checkbox column, selection keys, aria-selected, the batch bar |
batchActions | ({ selectedRows, clearSelection }) => ReactNode | — | Rendered in the batch bar |
sorting / defaultSorting / onSortingChange | { id: string; desc: boolean } | null | null | One sorted column |
columnVisibility / defaultColumnVisibility / onColumnVisibilityChange | Record<string, boolean> | {} | false hides a column |
rowSelection / defaultRowSelection / onRowSelectionChange | Record<string, boolean> | {} | Keyed by row id. Filtering prunes it to the rows in view |
globalFilter / defaultGlobalFilter / onGlobalFilterChange | string | "" | The search |
columnFilters / defaultColumnFilters / onColumnFiltersChange | { id: string; value: string | string[] }[] | [] | Column filter values |
pagination | boolean | false | Client-side pages with the Pagination footer |
page / defaultPage / onPageChange | number | 1 | 1-based, like Pagination |
pageSize / defaultPageSize / onPageSizeChange | number | first option | Rows per page |
pageSizeOptions | number[] | [10, 25, 50, 100] | Rows-per-page choices |
loading | boolean | false | Skeleton rows under the live header; aria-busy |
loadingRows | number | page size, else 5 | Skeleton row count |
empty | { icon?, title?, description?, action? } | "No data yet" | The no-data state |
error | { title: string; description?, icon?, action? } | — | A load failure; the title is announced |
maxHeight | number | string | — (min(30rem, 70dvh) with virtualize) | Bounds the scrollport — required for stickyHeader |
stickyHeader | boolean | false (true with virtualize) | Header cells stick to the scrollport's top |
resizable | boolean | false | A drag strip on each header, and width items in the column menus |
columnMenus | boolean | resizable | A menu on each header: sort, pin to start, hide, and width with resizable |
columnSizing / defaultColumnSizing / onColumnSizingChange | Record<string, number> | {} | Widths in px by column id; unset columns use width |
columnPinning / defaultColumnPinning / onColumnPinningChange | { start: string[] } | the pinned: "start" columns | Columns held at the start edge, in order |
virtualize | boolean | false | Render only the rows in view. Ignored with pagination |
onCellEdit | ({ rowId, row, columnId, value }) => void | — | A committed edit; editable columns need it |
The root forwards className, ref and any other attribute to its wrapping
<div data-slot="data-grid">.
DataGridColumn
| Field | Type | Default | Description |
|---|---|---|---|
accessor | keyof TData | (row) => unknown | — | Omit for a display column (actions): never sorts, filters or exports |
id | string | the accessor key | Required with a function accessor or none |
header | string | — | Plain-text name: header, columns menu, CSV, filter label, sort announcement |
headerNode | ReactNode | — | Visual header content; header stays the accessible name |
cell | ({ value, row, rowId }) => ReactNode | the value as text | value is typed from a key accessor |
align | "start" | "center" | "end" | "start" | |
numeric | boolean | — | End-aligned tabular numerals, header and cells |
width | number | string | 150 in a sized grid | A number is the width in px wherever the grid sizes columns (resizable, virtualized, pinned or editable); a hint otherwise |
minWidth / maxWidth | number | 64 / 960 | Resize bounds, in px |
enableResizing | boolean | true | false keeps a resizable grid's column fixed |
enablePinning | boolean | true | Offer Pin to start in the column menu |
pinned | "start" | — | Start pinned |
editable | boolean | { type: "text" } | { type: "number" } | { type: "select"; options } | — | Edit in place; needs onCellEdit |
validate | (value, row) => string | undefined | — | A message keeps the editor open |
rowHeader | boolean | first accessor column | Cells render as <th scope="row"> and name the row's checkbox |
enableSorting | boolean | true with an accessor | |
sortFn | (a: TData, b: TData) => number | by value | Order by meaning |
enableHiding | boolean | true | false locks the column in the columns menu |
filter | { type: "select" | "multi-select"; options: { value, label }[] } | — | A Select for this column in the filter bar |
exportValue | (row) => string | number | boolean | Date | null | the value | The CSV value — give one for object values |
Parts
The toolbar pieces are exported, for your own layout inside a DataGridRoot
(through toolbarStart/toolbarEnd): DataGridFilterBar,
DataGridColumnsMenu, DataGridDensityMenu, DataGridExportButton,
DataGridBatchBar, DataGridPagination — also as DataGrid.FilterBar and so
on. Each reads the grid around it.
useDataGrid
const { table, size, setSize, strings, locale, exportCsv } = useDataGrid<Member>();table is the TanStack Table v8 instance — the escape hatch for anything the
props do not cover. exportCsv({ rows: "all" | "filtered" | "selected", columns: "visible" | "all", filename, bom, escapeFormulas })
downloads a CSV. Throws outside a grid.
Strings
DATA_GRID_STRINGS holds the English and Arabic tables. dir="rtl" picks
Arabic; strings overrides any entry, with {count}, {total}, {column},
{row} and {page} placeholders and CLDR plural variants
(rowCount_one, selected_few, …).
DataGridSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size / variant / rounded | as the grid | "md" / "lined" / "md" | |
rows / columns | number | 5 / 4 | |
selectable | boolean | false | A checkbox column |
showToolbar / showPagination | boolean | false | Toolbar and pager bones |
label | string | "Loading rows" | Announced once |
Accessibility
DataGrid is a semantic <table role="grid"> implementing the
APG data grid pattern —
completely, because half a grid is worse than a table.
| Key | Does |
|---|---|
| Tab / Shift+Tab | Into the grid, onto the cell you last used; out again. The grid is one Tab stop |
| Arrow keys | Move between cells; stop at the edges. Left/Right flip under RTL |
| Home / End | First / last cell of the row |
| Ctrl/Cmd+Home / End | First cell of the grid (the header) / last cell |
| PageUp / PageDown | Up / down by a screenful of rows |
| Enter / Space on a header | Sort (its sort button holds the header's focus) — or, with column menus, open the column's menu |
| Enter / F2 on a cell with controls | Into the controls; Tab moves among them |
| Escape | Back from the controls to the cell |
| Space on a checkbox | Toggle the row |
| Shift+Space | Toggle the row the focus is in |
| Ctrl/Cmd+A | Select every row in view |
| Enter / F2 on an editable cell | Edit it. Enter commits and moves down; Tab / Shift+Tab commit and move along the row; Escape cancels |
| Left / Right on a column's splitter | Narrow / widen it (mirrored under RTL; Shift for bigger steps); Home / End to its bounds; Enter / Escape back to the header |
- Roles and state. Rows expose
aria-selectedand the gridaria-multiselectablewhile selection is on.aria-sortsits on each sortable header.aria-rowcount/aria-rowindexappear only when rows are off the page — APG's own rule. A usage hint is the grid'saria-describedby. - Announcements go through one polite status region: sort changes, the result count after a filter, a load error. The batch bar announces the selection count.
- A lone link, button or checkbox takes its cell's focus (APG examples 1–2); a cell with several controls takes the focus itself and hands it on with Enter or F2.
- Column menus keep Up and Down for the grid: they move to the next row rather than opening the menu. The resize strip stays out of the header's accessible name until you choose Resize with keyboard; then it is a labelled splitter with its width as the value.
- Virtualized rows carry
aria-rowindexand the gridaria-rowcount; the spacer rows between them are hidden from assistive tech. Screen readers that browse by virtual cursor only see the rows in the DOM — for a list people read top to bottom, preferpagination. - Focus is an inset ring inside the frame's clip, in the cell's own ink.
- Touch. Sort and menu buttons, checkboxes and toolbar controls keep the library's 44px floors on coarse pointers. The resize strip is 24px wide under a finger; the column menu's Widen and Narrow do the same job with single taps.
- Tested with role-based unit tests and axe in both themes. Screen readers: VoiceOver pending the beta smoke; NVDA and JAWS untested.