Data grid
A spreadsheet-grade grid on UI SDK: virtualized rows, sorting, filters, grouping, pinned, resizable and movable columns, row and cell selection, inline editing with validation, clipboard copy and paste, CSV export, a saved layout and agent actions.
A grid for real data, built on UI SDK: thousands of rows, spreadsheet keys, editing in
place, and actions an agent can call. One item, data-grid, gives you DataGrid,
DataGridToolbar and the framework-free pieces under them.
pnpm dlx shadcn@latest add superzero11/uiset/data-gridWhy it's built this way
UI SDK ships the hard behaviors a grid needs, and the grid is those behaviors wired together, not a rewrite of them:
| Need | UI SDK piece |
|---|---|
| One tab stop, arrows, Home/End, Page keys | createGridFocus |
| Select a block, rows or columns; copy it | createCellRange, textToCells |
| Edit in place: Enter, F2, typing, Escape | createInlineEdit |
| Render 10,000 rows, reveal one off screen | createVirtualizer |
| Column menu, View menu, filters, grouping | Menu, Popover, Select |
| Drag a column edge | bindDrag |
| Announce sorts, counts and edits | announce |
| Agents sort, filter, select and edit | the primitive's agent key |
Sorting, filtering, grouping and CSV export are plain functions in data-grid/view.ts,
so they run on a server or in a worker too.
Use
import { DataGrid, DataGridToolbar, type DataGridColumn } from "@/components/ui/data-grid"
const columns: DataGridColumn<Order>[] = [
{ id: "id", header: "Order", value: (o) => o.id, pinned: true, hideable: false },
{ id: "customer", header: "Customer", value: (o) => o.customer, editable: true },
{ id: "status", header: "Status", value: (o) => o.status, options: ["Paid", "Pending"], editable: true },
{
id: "amount",
header: "Amount",
type: "number",
value: (o) => o.amount,
text: (o) => money.format(o.amount),
editable: true,
},
{ id: "rush", header: "Rush", type: "boolean", value: (o) => o.rush, editable: true },
]
<DataGrid
label="Orders"
rows={orders}
columns={columns}
getRowId={(o) => o.id}
selectable
onCellEdit={({ rowId, column, value }) => save(rowId, column, value)}
persist="orders-grid"
height="30rem"
>
<DataGridToolbar filename="orders" selectionActions={<Button>Mark paid</Button>} />
</DataGrid>
valueis what sorts and filters;textis what shows and copies;renderreplaces what shows (a badge, a link) without changing either.typesets how an edit is read, with UI SDK'sparseNumberandparseDateinlocale:"number"follows the locale's grouping and accepts a currency sign ($1,200.50) but refuses words (12 units);"date"reads "Nov 20", "2026-11-20" or the locale's order and stores that calendar day;"boolean"flips with Space.optionslimits an edit to a list and suggests it.onCellEditgets the parsed value. Returnfalseto keep the editor open. Without it, nothing is editable.sort,query,filters,groupBy,selected,columnStateanddensitycan each be controlled (keep filters in the URL, say), with adefault…and anon…Change.- Pass
localewhen rendering on a server, so counts format the same on both sides. persistsaves column order, widths, visibility, pinning and density inlocalStorage, read after the first render so server markup matches.
Toolbar parts
DataGridToolbar is one arrangement of parts you can use on their own, anywhere inside
a DataGrid: DataGridSearch, DataGridFilters, DataGridGroupBy, DataGridViewMenu,
DataGridExport and DataGridSelection (the count, your bulk actions, Clear selection).
useDataGrid() gives a custom part the same state.
<DataGrid label="Orders" rows={orders} columns={columns} getRowId={(o) => o.id} selectable>
<div className="my-toolbar">
<DataGridSearch />
<DataGridSelection>
<Button>Mark paid</Button>
</DataGridSelection>
<DataGridExport filename="orders" />
</div>
</DataGrid>
Keys
| Keys | Where | Does |
|---|---|---|
| Arrows, Home, End, Page keys | anywhere | Move one cell, to the row's ends, by a page |
| Control+Home, Control+End | anywhere | First cell, last cell (rows render on the way) |
| Enter, Space | header | Sort ascending, descending, off |
| Shift+Enter | header | Add the column to the sort |
| Shift+F10 or the Menu key | header | Column menu: sort, group, pin, move, hide |
| Alt+Left, Alt+Right | header | Narrower, wider (Option on a Mac) |
| Alt+Shift+Left, Alt+Shift+Right | header | Move the column |
| Enter, F2, or start typing | editable cell | Edit; Enter saves, Escape cancels, Tab saves |
| Space | yes/no cell | Flip it |
| Space | first column | Check the row |
| Enter | group row | Open or close the group |
| Shift+Arrows, Shift+click | anywhere | Select a block |
| Shift+Space, Control+Space | anywhere | Select the rows, the columns |
| Control+A, Escape | anywhere | Select all cells, clear the block |
| Control+C, Control+V | anywhere | Copy the block; paste into editable cells |
The grid's description lists these for screen reader users, and every sort, resize, move, paste and filter result is announced. Each header also has a column menu button for pointers, touch and touch screen readers (it never takes the tab stop), and the menu has Wider and Narrower, so resizing never needs a drag.
For agents
The grid offers sortBy, filter, filterColumn, selectRows, clearSelection,
editCell, groupBy and showColumn. Columns are named by header and rows by their id
or first column, the way a person reads them. The snapshot shows what the grid is doing
(rows shown, sort, filter, grouping, selection), and query returns every row, including
the ones not rendered.
A step that can't run says why, so the agent can try again:
No column "Total" in Orders. Its columns: Order, Customer, Region, Status, Amount, Placed, Rush.
Amount needs a number
Status must be one of Paid, Pending, Refunded, Failed
Edits go through the same parsing and onCellEdit as a person's, so your validation
applies to both.
Accessibility notes
role="grid"witharia-rowcountandaria-rowindex, so screen readers know the full size while only visible rows render.- The primary sort column carries
aria-sort. Group cells carryaria-expanded(a grid row may not; only a treegrid's can). - Row checkboxes are real checkboxes that never take focus; the cell keeps it, so the arrows keep working after a click. Shift-click ranges follow the rows on screen.
- The editor covers its cell, says what's wrong with
aria-invalidand a message, and announces it. - Range outlines follow the reading direction, and the focused cell's ring is an outline, so both hold in right-to-left and in forced colors.
Limits, for now
- Rows have one height per density; wrapping text is cut with an ellipsis.
- Columns aren't virtualized; hundreds of columns will be slow.
- Find in page only finds rendered rows; use the search box.
createVirtualizer'sfindablecan lift this later.