DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CardCarouselTableDataGridCodeBlock
AI agentsContributing
  1. Components
  2. DataGrid

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/ui

When 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 editable and write the change back in onCellEdit.
  • Thousands of rows in one scroll. Use virtualize, or pagination, which 15 of the 30 surveyed products chose.
  • A named, stable grid. Give every grid a label or caption, and a stable getRowId when 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

Terminal
pnpm add @tessinaui/ui

DataGrid 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

example.tsx
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

PropTypeDefaultDescription
dataTData[]—The rows
columnsDataGridColumn<TData>[]—See DataGridColumn below
getRowId(row, index) => stringrow indexA stable id — selection survives sorting, filtering and refetches only with one
labelstring—The grid's accessible name. Required unless caption names it (dev-warn)
captionReactNode—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"inheritedStamped only when set. An explicit "rtl" also picks the Arabic strings
localestring"ar" under dir="rtl", else "en-US"Plural rules for the counts
stringsPartial<DataGridStrings>—Per-entry overrides of the strings table
showFilterBarbooleanfalseSearch, column filters, the result count and Clear filters
showColumnsMenubooleanfalseColumn visibility menu
showDensityMenubooleanfalseCompact / Default / Comfortable
showExportboolean | DataGridCsvOptionsfalseCSV export; options apply to every export
toolbarStart / toolbarEndReactNode—Your controls in the toolbar; useDataGrid() works inside
selectablebooleanfalseCheckbox column, selection keys, aria-selected, the batch bar
batchActions({ selectedRows, clearSelection }) => ReactNode—Rendered in the batch bar
sorting / defaultSorting / onSortingChange{ id: string; desc: boolean } | nullnullOne sorted column
columnVisibility / defaultColumnVisibility / onColumnVisibilityChangeRecord<string, boolean>{}false hides a column
rowSelection / defaultRowSelection / onRowSelectionChangeRecord<string, boolean>{}Keyed by row id. Filtering prunes it to the rows in view
globalFilter / defaultGlobalFilter / onGlobalFilterChangestring""The search
columnFilters / defaultColumnFilters / onColumnFiltersChange{ id: string; value: string | string[] }[][]Column filter values
paginationbooleanfalseClient-side pages with the Pagination footer
page / defaultPage / onPageChangenumber11-based, like Pagination
pageSize / defaultPageSize / onPageSizeChangenumberfirst optionRows per page
pageSizeOptionsnumber[][10, 25, 50, 100]Rows-per-page choices
loadingbooleanfalseSkeleton rows under the live header; aria-busy
loadingRowsnumberpage size, else 5Skeleton 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
maxHeightnumber | string— (min(30rem, 70dvh) with virtualize)Bounds the scrollport — required for stickyHeader
stickyHeaderbooleanfalse (true with virtualize)Header cells stick to the scrollport's top
resizablebooleanfalseA drag strip on each header, and width items in the column menus
columnMenusbooleanresizableA menu on each header: sort, pin to start, hide, and width with resizable
columnSizing / defaultColumnSizing / onColumnSizingChangeRecord<string, number>{}Widths in px by column id; unset columns use width
columnPinning / defaultColumnPinning / onColumnPinningChange{ start: string[] }the pinned: "start" columnsColumns held at the start edge, in order
virtualizebooleanfalseRender 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

FieldTypeDefaultDescription
accessorkeyof TData | (row) => unknown—Omit for a display column (actions): never sorts, filters or exports
idstringthe accessor keyRequired with a function accessor or none
headerstring—Plain-text name: header, columns menu, CSV, filter label, sort announcement
headerNodeReactNode—Visual header content; header stays the accessible name
cell({ value, row, rowId }) => ReactNodethe value as textvalue is typed from a key accessor
align"start" | "center" | "end""start"
numericboolean—End-aligned tabular numerals, header and cells
widthnumber | string150 in a sized gridA number is the width in px wherever the grid sizes columns (resizable, virtualized, pinned or editable); a hint otherwise
minWidth / maxWidthnumber64 / 960Resize bounds, in px
enableResizingbooleantruefalse keeps a resizable grid's column fixed
enablePinningbooleantrueOffer Pin to start in the column menu
pinned"start"—Start pinned
editableboolean | { type: "text" } | { type: "number" } | { type: "select"; options }—Edit in place; needs onCellEdit
validate(value, row) => string | undefined—A message keeps the editor open
rowHeaderbooleanfirst accessor columnCells render as <th scope="row"> and name the row's checkbox
enableSortingbooleantrue with an accessor
sortFn(a: TData, b: TData) => numberby valueOrder by meaning
enableHidingbooleantruefalse 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 | nullthe valueThe 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

example.tsx
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

PropTypeDefaultDescription
size / variant / roundedas the grid"md" / "lined" / "md"
rows / columnsnumber5 / 4
selectablebooleanfalseA checkbox column
showToolbar / showPaginationbooleanfalseToolbar and pager bones
labelstring"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.

KeyDoes
Tab / Shift+TabInto the grid, onto the cell you last used; out again. The grid is one Tab stop
Arrow keysMove between cells; stop at the edges. Left/Right flip under RTL
Home / EndFirst / last cell of the row
Ctrl/Cmd+Home / EndFirst cell of the grid (the header) / last cell
PageUp / PageDownUp / down by a screenful of rows
Enter / Space on a headerSort (its sort button holds the header's focus) — or, with column menus, open the column's menu
Enter / F2 on a cell with controlsInto the controls; Tab moves among them
EscapeBack from the controls to the cell
Space on a checkboxToggle the row
Shift+SpaceToggle the row the focus is in
Ctrl/Cmd+ASelect every row in view
Enter / F2 on an editable cellEdit it. Enter commits and moves down; Tab / Shift+Tab commit and move along the row; Escape cancels
Left / Right on a column's splitterNarrow / 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-selected and the grid aria-multiselectable while selection is on. aria-sort sits on each sortable header. aria-rowcount/aria-rowindex appear only when rows are off the page — APG's own rule. A usage hint is the grid's aria-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-rowindex and the grid aria-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, prefer pagination.
  • 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.
TableCodeBlock

On this page

When to useWhen not to useInstallationUsageNarrow screensExamplesDefaultSortingFilter barSelection and batch actionsColumn visibilityDensityCSV exportPaginationControlled stateEmpty and loadingLoad errorSkeletonOn colorRTLColumn menus and resizingPinned columnsVirtualized rowsInline editingEverything at onceAPI ReferenceDataGridRootDataGridColumnPartsuseDataGridStringsDataGridSkeletonAccessibility