Composites
Data Grid
A spreadsheet for bulk entry: edit in the cell, virtualised rows, keyboard navigation and range selection, copy–cut–paste against Excel, pinned and resizable columns, multi-criteria filtering and sorting, find in table, undo and redo. Added in 1.11.0, ported from tablecn onto the package's own primitives and tokens.
It has its own entry, and that is not a preference. The package emits one ES module per source file and leaves the imports intact, so a single re-export of a file that imports @tanstack/react-table would make every app that writes import { Button } from '@comitor/ui' install that package or fail at resolve time. So the grid lives at @comitor/ui/data-grid, and @tanstack/react-table is an optional peer you install yourself — a peer rather than a dependency so your ColumnDef and the grid's come from the same copy of the types.
Data Grid or Data Table?
They are not competing. The question is how much work the list is: a short list people read and open, or a working list with many fields that people filter, sort and correct as they go.
| Data Table | Data Grid | |
|---|---|---|
| The screen | A short list you scan and open — a handful of columns, a filter or two | A working list with many fields — tasks, customers, orders, stock |
| Filtering and sorting | The app filters (SearchFilterBar, FilterChips) and sorts one column at a time | Filter and Sort menus with several conditions each; chips can write the same filters |
| Entry | @comitor/ui | @comitor/ui/data-grid |
| Peer to install | none | @tanstack/react-table |
| Rows | A page at a time, with Table Pagination underneath | Virtualised — tens of thousands scroll smoothly |
| Editing | None. A row opens a detail view | In the cell, nine cell kinds — or none: readOnly for the grid, meta.readOnly per column |
| Clipboard | — | Copy, cut and paste a range against Excel |
| Markup | A real <table> with <th scope> | A composed role="grid" — virtualisation rules out a real table |
| Server rendering | Renders its rows | Renders the frame; rows need a measured viewport |
For a short list people read and open — members, invoices, a week of attendance — take Data Table: it is lighter, it needs no extra peer, and its real <table> keeps the browser's own table navigation working. Take the grid as soon as a list has many fields that people filter and sort by several conditions at once, or fix in passing — even when most of them are read-only. The task, contact, employee, order and inventory lists in Patterns are all grids for that reason.
Install
Nothing to configure beyond the usual stylesheet. The grid pulls @tanstack/react-virtual and @dnd-kit/* in as its own dependencies; only the table peer is yours.
pnpm add @comitor/ui @tanstack/react-tableBasic usage
useDataGrid holds everything and hands the result to DataGrid whole: destructure table out and spread the rest. Click a cell and type, or press Enter to edit it. Drag across cells to select a range, then Ctrl+C. The first two columns are pinned — scroll sideways and they stay.
- The grid holds no data.
onDataChangehands back the whole new array and the app decides what to do with it — local state, an optimistic save, a queued mutation. No request is ever made from inside the grid. meta.cell.variantis what makes a column a date picker rather than a text box, andmeta.labelis the name the Filter, Sort and Columns menus print. A column withoutfilterFncannot be filtered.- Keep
columnsinside auseMemo. A fresh array on every render throws away the column widths the user dragged.
The nine cell kinds
Declared per column in meta.cell. The variant decides three things at once: how the value is displayed, how it is edited, and which filter operators the Filter menu offers for it.
| Prop | Type | Default | Description |
|---|---|---|---|
short-text | string | — | Typed straight into the cell. The default for anything textual. |
long-text | string | — | Opens a textarea in a popover; Ctrl+Enter commits. Wraps to the row height. |
number | number | null | — | Numeric input. Takes min, max and step in meta.cell. |
url | string | null | — | Rendered as a link and opened in a new tab. javascript:, data:, vbscript: and file: are refused, and a bare host gets an http:// prefix rather than being treated as a path. |
select | string | null | — | One value from meta.cell.options. Each option may carry an icon and a count. getOptions(rowId) (3.6.1) narrows what is offered while one row's cell is open — options stays the full set. |
multi-select | string[] | — | Several values from the same option list, shown as badges that overflow into a +N. |
checkbox | boolean | — | A real checkbox. Space toggles it while the cell is focused. |
date | string | null | — | Stored as "yyyy-MM-dd". Displayed as the short date of the grid's language (date-fns "P": 09/04/2026 in English, 04/09/2026 in Vietnamese) and edited in a calendar popover. A pasted date is read in that same format first and needs a four-digit year. |
file | FileCellData[] | — | Attachments with drag and drop. maxFileSize (10MB), maxFiles (10), accept and multiple are set in meta.cell. Without onFilesUpload the files are blob URLs that die on reload. |
Every variant above is in the demos on this page except file — the Lane column is a select, Tags is a multi-select, Notes is a long-text, Tracking is a url.
A select or multi-select draws its value as a grey badge unless you pass renderValue — then the cell draws it with your own component, the one the detail view already uses, and keeps its editor, the keyboard, paste, filtering and find. For a select the function also draws each choice in the dropdown, so it has to return inline content (a span); for a multi-select it draws the whole set and folds it itself. Filtering, sorting, find and copy keep reading the option labels. The Tasks pattern draws status and priority with StatusPill, labels with BadgeOverflow, and assignees with an AvatarGroup set to interactive={false}.
// Since 3.2.0: select and multi-select cells can draw their value with your own components —
// the same StatusPill and AvatarGroup as the detail view — and still keep their editor.
{ id: 'status', accessorKey: 'status', header: 'Status', filterFn,
meta: { label: 'Status', cell: { variant: 'select', options: STATUS_OPTIONS,
renderValue: (value) => <StatusPill configs={TASK_STATUSES} value={value as TaskStatus} /> } } },
{ id: 'assigneeIds', accessorKey: 'assigneeIds', header: 'Assignees', filterFn,
meta: { label: 'Assignees', cell: { variant: 'multi-select', options: MEMBER_OPTIONS,
// The cell is already a button: no tooltip buttons inside it.
renderValue: (ids) => <AvatarGroup items={toAvatarItems(ids)} size="xs" max={3} interactive={false} /> } } },Since 3.6.1 a select can offer each row its own list while the cell is being edited: getOptions(rowId) receives the row's id (your getRowId) and returns the choices — the statuses of the group a task belongs to, say — or undefined to fall back to options. options still has to be the full set: the resting cell, filters, facet counts, find and paste all read it. Do not try the same by rebuilding columns per row — rows are memoised and ignore a changed column definition, so the open cell would never see it.
Toolbar
Five separate pieces, each taking the table instance. Filter and Sort both take several criteria and reorder by dragging. The Columns menu hides columns — set enableHiding: false on any that must stay. Press Ctrl+F to search, Ctrl+/ for the shortcut sheet, and use the footer row to add a record.
Column visibility, pinning and widths all live in table.getState(). Read them out to persist a per-user layout and hand them back through initialState.
// Column visibility, pinning and widths all live in table.getState(). Read them out to save a
// per-user layout, and feed them back through initialState on the next mount.
const { columnVisibility, columnPinning, columnSizing } = table.getState()
const { table } = useDataGrid<Shipment>({
data,
columns,
getRowId: (row) => row.id,
initialState: saved ?? { columnPinning: { left: ['select', 'reference'] } },
})Virtualisation
There is no prop to turn this on — it is how the grid renders, always, and it is the reason the grid exists separately from Data Table. The demo below holds 10,000 rows. The second counter reads the live DOM, so scroll the grid and watch it stay flat while the first number does not move either.
heightis what makes it work. It bounds the scroll box. Leave it off and the grid grows to the full row count, the page scrolls instead of the grid, and every row is in the DOM — virtualised in name only. A number is px; a string is any CSS value, socalc(100dvh - 220px)fills what is left of the screen.overscan(6 by default) is how many rows are kept beyond the viewport on each side. Raising it trades memory for fewer blank frames during a fast flick; lowering it does the reverse.- Row height feeds the maths, which is why
rowHeightis a fixed scale — 36, 56, 76 and 96px — rather than free-form. Cells that would overflow clamp to the line count instead of pushing the row taller, so every row stays the height the virtualiser assumed. - The trade the design accepts: this cannot be a real
<table>, because only a fraction of the rows exist at any moment. Hence the composedrole="grid"with explicitaria-rowcount— the count has to be declared, because it cannot be derived from what is present. - Keep the
datareference stable. A new array identity on every render re-measures the whole list, which is exactly the cost virtualisation was there to avoid.
Keyboard and clipboard
The whole point of a grid over a form is that the hands never leave the keyboard. The bindings follow the spreadsheet everybody already knows.
Arrows / Tab | Move the focused cell. Ctrl+Arrow jumps to the edge of the block. |
Enter / F2 | Start editing the focused cell. Escape cancels, Enter commits and moves down. |
Any character | Starts editing and replaces the cell — the spreadsheet reflex. |
Shift+Arrow | Extend the selection. Drag with the mouse does the same. |
Ctrl+C / Ctrl+X | Copy or cut the selected range as TSV. |
Ctrl+V | Paste. More rows than exist and the grid asks before it adds them. |
Ctrl+F | Find in table, with next and previous match. |
Shift+Enter | Add a row. |
Ctrl+Backspace | Delete the selected rows. |
Ctrl+/ | Open the shortcut sheet. |
- Copy and paste survive an http origin.
navigator.clipboardandcrypto.randomUUIDonly exist in a secure context, so the grid falls back todocument.execCommand('copy'), reads the payload off the paste event itself, and generates file ids by hand. An app on an internalhttp://10.x.x.xkeeps working. - Pasting more rows than the table has opens a dialog rather than silently discarding the tail, and adding them goes through
onRowsAdd(count)— one call, not one per row. - Keys of your own —
J/Kto move,Xto tick — move focus withtableMeta.focusCell(rowIndex, columnId)(3.6.1), notonCellClick. That one is the path of a click: it clears ticked rows, and “clicking” the focused cell again opens its editor.focusCelltouches neither and does not scroll — pair it withscrollToCell. Since 3.6.1 ticking a row also keeps the focused cell; select all and clear all still drop it.
Undo and redo
A separate hook, deliberately: the history is browser-side and the grid does not own it. It tracks cell edits, row additions and row deletions, keyed by getRowId rather than by row position — so a step still lands on the right record after the rows are re-sorted.
Pass the same getRowId to both the grid and the history. Two different functions means the history writes its steps against keys the grid never uses, and undo silently does nothing.
Read only
readOnly locks editing, deletion and pasting and drops the footer row. Pass it to getDataGridSelectColumn as well and the checkbox column becomes a plain row number — sorting, filtering, pinning and search all keep working.
Often only some columns are off limits — a key, or a value the app works out itself. Set meta.readOnly on those columns (since 3.1.0) and leave the grid editable: the locked cells keep their kind, their filter operators and their place in a copied range, but no editor opens on them, and typing, pasting, clearing and cutting all pass them by — a pasted block that spans a locked column fills the columns around it. The task grid in Patterns locks Key and Comments this way.
// One column, not the whole grid (since 3.1.0): shown, filtered, sorted and copied
// like any other — but no editor opens, and typing, pasting, clearing and cutting skip it.
{ id: 'sku', accessorKey: 'sku', header: 'SKU', filterFn,
meta: { label: 'SKU', readOnly: true, cell: { variant: 'short-text' } } },
{ id: 'onHand', accessorKey: 'onHand', header: 'On hand', filterFn,
meta: { label: 'On hand', cell: { variant: 'number', min: 0 } } },Loading
DataGridSkeleton is only the frame: on its own it draws nothing, so put DataGridSkeletonToolbar and DataGridSkeletonGrid inside it, or just the grid when the toolbar is already live. The frame fills the screen below a 4rem header by default; give it the grid’s own height with a class, as here. Since 3.1.0 that class wins with a toolbar inside too — before, the frame kept the full-screen height whatever you passed.
Collaborator presence
Optional, and transport-agnostic. Wrap the grid in DataGridPresenceProvider with a map of cell keys to { name, color } and each of those cells gets a ring in that person's colour. Where the map comes from — a websocket, a polling endpoint, a local mock — is entirely yours.
import { DataGridPresenceProvider } from '@comitor/ui/data-grid'
// Keys are `${rowIndex}:${columnId}` — the same shape getCellKey() produces. Each entry paints a
// ring in that collaborator's colour on the cell they are in.
const presence = new Map([
['3:consignee', { name: 'Mai Tran', color: '#2E7D6B' }],
])
<DataGridPresenceProvider value={presence}>
<DataGrid {...gridProps} table={table} aria-label="Shipments" />
</DataGridPresenceProvider>Language
Two built-in tables — "en" and "vi" — and a door for one you write yourself. Since 3.0.0 the grid follows ComitorLocaleProvider like every other component, so an app that sets its language once in the root layout has nothing to do here. Flip the toggle below: it swaps a ComitorLocaleProvider, nothing on the grid names a language, and the toolbar, the grid, the menus, the dates in the ETD column and the copy toasts all move together. Column headers and option labels are your content, so they stay as written.
<ComitorLocaleProvider locale="en">The grid still keeps its own tables rather than a namespace in the shared one: most of its strings come from tablecn upstream, so keeping English as the source table means checking a string against upstream is a direct comparison rather than a double translation. That is also why it was the first component to default to English, in 2.0.0 — a release before the rest of the package followed.
Overriding one grid? Then two places declare it. The order is the component's own locale prop, then the nearest DataGridLocaleProvider, then ComitorLocaleProvider, then English. A root provider reaches everything, but a grid-level override has two audiences. DataGridLocaleProvider covers the grid and the toolbar — the toolbar sits outside <DataGrid>, wherever you put it, so a prop on the grid never reaches it. useDataGrid({ locale }) covers the toasts — those render at the app root through <Toaster />, and the hook reads its language when it is called, from the ancestors of the component calling it, so a provider rendered by that same component is out of its reach. Declare one and not the other and the screen ends up bilingual in exactly the place you are least likely to look: an English toolbar over a Vietnamese grid, or an English “3 cells copied” after a Vietnamese action.
Rather not wrap a provider? Pass locale to each menu instead. The prop beats the provider, so one screen can hold two grids in two languages.
import {
DataGrid,
DataGridFilterMenu,
DataGridLocaleProvider,
DataGridSortMenu,
useDataGrid,
} from '@comitor/ui/data-grid'
// One grid in a language of its own. Precedence, highest first:
// the component's locale prop → DataGridLocaleProvider → ComitorLocaleProvider → 'en'
const { table, ...gridProps } = useDataGrid<Shipment>({
data,
columns,
getRowId: (row) => row.id,
onDataChange: setData,
locale: 'vi', // for the toasts — the provider below is out of this hook's reach
})
<DataGridLocaleProvider locale="vi">
<DataGridFilterMenu table={table} />
<DataGridSortMenu table={table} />
<DataGrid {...gridProps} table={table} aria-label="Danh sách lô hàng" />
</DataGridLocaleProvider>
// Without locale, DataGridLocaleProvider overrides nothing: it forwards the parent provider, or
// the ComitorLocaleProvider language. To pin English, say so: locale="en".
// Or skip the provider and pass it per menu. The prop beats the provider.
<DataGridFilterMenu table={table} locale="vi" />- An empty provider forwards.
DataGridLocaleProviderwithoutlocalepasses on the parent's language, or theComitorLocaleProviderone; passlocale="en"to pin English. Before 3.0.0 an empty provider forced English, and<DataGrid>wrapped itself in one — which turned a grid with no locale of its own English even insideDataGridLocaleProvider locale="vi". That was a bug, and it is gone. - Dates follow the language. Cells, the date filter and the calendars use date-fns
"P"for the grid's language — 09/04/2026 in English, 04/09/2026 in Vietnamese — and take the provider's date-fns locale when the languages match, sodateFnsLocale={enGB}on the provider gives British order. A pasted date is tried in that short format first and needs a four-digit year:25/12/2024pastes into a Vietnamese grid, and01/02/2024is 1 February there. formatDateandformatDateForDisplay, exported for matching the grid elsewhere on a screen, are pure functions and cannot see the grid's language: pass the date-fns locale (vifordd/MM/yyyy). Without it they use date-fns' default, en-US. Before 3.0.0 they always returneddd/MM/yyyy.- The filter operators and the column-type tooltips in the headers are translated too, and the drag-and-drop announcements of the Filter and Sort menus live in the grid's table under
sortable. Before 3.0.0 the first two stayed English in a Vietnamese grid and the announcements were hard-coded Vietnamese.
import { getDataGridMessages, DataGridLocaleProvider } from '@comitor/ui/data-grid'
// locale also accepts a whole table, so changing wording for your domain needs no fork.
const vi = getDataGridMessages('vi')
const messages = { ...vi, grid: { ...vi.grid, addRow: 'Thêm bệnh nhân' } }
// A table of your own says nothing about dates, so they follow the next layer:
// the parent DataGridLocaleProvider, then ComitorLocaleProvider.
<DataGridLocaleProvider locale={messages}>…</DataGridLocaleProvider>
// The shape is defined by the English table, so a key you forget to translate is a type error,
// not an undefined string that reaches the screen.Server and client
Everything in @comitor/ui/data-grid is a client component, and useDataGrid has to be called inside one. Rendered on the server the grid emits its frame — the role="grid" element, the header and the footer — but no rows: virtualisation needs a viewport it can measure, and on the server there is none. That is correct behaviour, not a bug to design around.
More on where the package draws that line: Server and client.
DataGrid props
On top of everything useDataGrid returns, and every <div> prop except contextMenu, which the grid uses itself.
| Prop | Type | Default | Description |
|---|---|---|---|
tablerequired | Table<TData> | — | The TanStack table instance returned by useDataGrid. Pass it alongside the rest of the hook result. |
height | number | string | — | Maximum height of the scroll box. A number is px; a string is any CSS value, so calc(100dvh - 220px) lets the grid fill what is left of the page. |
stretchColumns | boolean | false | Stretch the columns to fill the width when their total is narrower than the frame. |
dir | "ltr" | "rtl" | "ltr" | Reading direction. Left unset the grid reads it from Radix's DirectionProvider, so an RTL app only has to declare it once. |
locale | "en" | "vi" | DataGridMessages | from provider | The grid's language, or a whole table of your own. Unset, it follows DataGridLocaleProvider, then ComitorLocaleProvider, then English. It usually arrives in the useDataGrid result, so there is no need to repeat it; set here, it beats that value. The toolbar never sees it. |
aria-label | string | — | Names the grid. It is placed on the element that actually carries role="grid" — the outer div is only a layout box, and a label there is ignored by screen readers. aria-labelledby and aria-describedby work the same way. |
useDataGrid options
Extends TanStack's TableOptions, so everything that library accepts — state, onSortingChange, getFilteredRowModel — works here too.
| Prop | Type | Default | Description |
|---|---|---|---|
datarequired | TData[] | — | The rows. Keep the reference stable between renders. |
columnsrequired | ColumnDef<TData>[] | — | TanStack column definitions. Keep them in a useMemo — rebuilding the array on every render resets column sizing. |
getRowIdrequired | (row: TData) => string | — | Stable record key. Must match the getRowId passed to useDataGridUndoRedo. |
onDataChange | (data: TData[]) => void | — | Receives the whole new array after every edit, paste or clear. The hook calls no API of its own — save it wherever you like, usually optimistically with a rollback on failure. |
onRowAdd | (event?) => Partial<CellPosition> | Promise<...> | null | — | Fired by the "add row" footer and by Shift+Enter. Return the cell to focus afterwards, or null to leave the viewport alone. Omit the prop and the footer does not render. |
onRowsAdd | (count: number) => void | Promise<void> | — | Pasting more rows than exist. Adds count rows in one call rather than one call per row. |
onRowsDelete | (rows: TData[], rowIndices: number[]) => void | boolean | Promise<void> | Promise<boolean | undefined> | — | Row deletion from the context menu or Ctrl+Backspace. You get the records and their positions in the order on screen — not indices into your data, so delete by the records (or their ids). Return or resolve false when nothing was deleted, such as a cancelled confirmation: the grid keeps the selection and shows no toast (from 3.3.3). |
onPaste | (updates: CellUpdate[]) => void | Promise<void> | — | Runs before pasted data is applied — the hook for a bulk update endpoint. |
onFilesUpload | (params) => Promise<FileCellData[]> | — | Upload handler for file cells. Without it attachments are browser blob URLs and are lost on reload. |
enableSearch | boolean | false | Find-in-table on Ctrl+F, with next/previous match navigation. |
enablePaste | boolean | false | Paste from the clipboard on Ctrl+V, including TSV straight out of Excel. |
enableSingleCellSelection | boolean | false | Clicking a cell selects it. The default is focus only. |
enableColumnSelection | boolean | false | Clicking a column header selects the whole column. |
readOnly | boolean | false | Locks editing, deletion and pasting, and hides the "add row" footer. |
rowHeight | "short" | "medium" | "tall" | "extra-tall" | "short" | Initial row height: 36, 56, 76 and 96px. Multi-line cells clamp to match. |
overscan | number | 6 | Rows rendered beyond the viewport on each side. |
autoFocus | boolean | Partial<CellPosition> | — | Focus the first cell — or a named one — once data arrives. |
initialState | InitialTableState | — | TanStack initial state. Where columnPinning, columnVisibility and columnSizing are restored from. |
locale | "en" | "vi" | DataGridMessages | from provider | Language of the toasts the hook fires and of pasted-date parsing, resolved where the hook is called: this value, then a DataGridLocaleProvider above the calling component, then ComitorLocaleProvider, then English. Passed on to DataGrid in the result. |
getDataGridSelectColumn options
Returns a ColumnDef, so it goes first in the array and takes any other column field you want to override.
| Prop | Type | Default | Description |
|---|---|---|---|
enableRowMarkers | boolean | false | Show the row number at rest and swap it for the checkbox on hover, on keyboard focus, or once the row is selected. |
readOnly | boolean | false | Drop the checkboxes and leave a plain row number. |
hitboxSize | "default" | "sm" | "lg" | "default" | How far the click target extends past the checkbox, to match the row height in use. |
debug | boolean | false | Paint the hitbox so you can see where it actually reaches. Development only. |
useDataGridUndoRedo options
| Prop | Type | Default | Description |
|---|---|---|---|
datarequired | TData[] | — | The same array the grid holds. |
onDataChangerequired | (data: TData[]) => void | — | Where an undone or redone array is handed back. |
getRowIdrequired | (row: TData) => string | — | Must be the same function the grid uses — history is keyed by record, not by row position. |
maxHistory | number | 100 | How many steps are kept. |
enabled | boolean | true | Turn tracking off without unmounting the hook. |
locale | "en" | "vi" | DataGridMessages | from provider | Language of the hook's own toasts (Nothing left to undo…), resolved like useDataGrid's. Pass the same value you gave useDataGrid when the grid's provider sits inside the calling component. |
Accessibility
- The grid is a composed
role="grid"withrole="rowgroup",role="row"andaria-rowcount/aria-colcount. Unlike Data Table, this one cannot be a real<table>: only a fraction of the rows exist in the DOM at any moment, so the counts have to be declared rather than derived. - Name every grid with
aria-labeloraria-labelledby. It lands on therole="grid"element, not on the outer layout div — a label there is invisible to a screen reader. - One tab stop. Tab reaches the grid, the arrows move inside it, and Tab again leaves — the roving
tabIndexpattern. A grid where every cell is its own tab stop is unusable with 10,000 rows. - Selection is never signalled by tint alone. The selected range is
--accent, the package's state tint, and the cell that carries the information is the focused one with its ring. A search hit is outlined in--warning-ink— 6.51:1 in light, 11.60:1 in dark, against the cell — rather than washed with a 10% fill that measures 1.2:1 and tells the reader nothing. - A locked row marker renders
<span role="link" aria-disabled tabIndex={0}>— thetabIndexis required, not stray: an element with a link role that cannot be focused fails both the linter and the reader. - The row number in the select column hides with
visibility, notopacity. It leaves the accessibility tree instead of being read out at zero contrast. The checkbox next to it keepsopacityon purpose — hiding it withvisibilitywould take it out of the tab order and make row selection mouse-only — and turns opaque onfocus-visible, with the row number stepping aside, so Tab never lands on a box nobody can see. - Every cell button has a name. A cell with a value takes it from its content, your
renderValueincluded; since 3.11.1 an empty one —null, a blank string, an empty array — carriesaria-label“Status: empty” (“Status: trống” in Vietnamese) instead of being a button with no name. The column part comes from the same place as the header:meta.label, then a stringheader, then theid. The sentence is the message keycell.empty; a message table written by hand needs it to typecheck, and falls back to the English one at run time. - A column header button is named by its title alone. The type icon, the chevron and every column-menu icon are
aria-hiddenexplicitly since 3.11.1 — before, an icon that Radix had given anaria-describedbywhile its tooltip was open slipped into the button's name. - Colour is not the only signal anywhere: sort state changes the icon shape, filters show as chips with text, and the search hit carries an outline rather than a wash.