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-grid

Why 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:

NeedUI SDK piece
One tab stop, arrows, Home/End, Page keyscreateGridFocus
Select a block, rows or columns; copy itcreateCellRange, textToCells
Edit in place: Enter, F2, typing, EscapecreateInlineEdit
Render 10,000 rows, reveal one off screencreateVirtualizer
Column menu, View menu, filters, groupingMenu, Popover, Select
Drag a column edgebindDrag
Announce sorts, counts and editsannounce
Agents sort, filter, select and editthe 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

tsx
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>
  • value is what sorts and filters; text is what shows and copies; render replaces what shows (a badge, a link) without changing either.
  • type sets how an edit is read, with UI SDK's parseNumber and parseDate in locale: "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. options limits an edit to a list and suggests it.
  • onCellEdit gets the parsed value. Return false to keep the editor open. Without it, nothing is editable.
  • sort, query, filters, groupBy, selected, columnState and density can each be controlled (keep filters in the URL, say), with a default… and an on…Change.
  • Pass locale when rendering on a server, so counts format the same on both sides.
  • persist saves column order, widths, visibility, pinning and density in localStorage, 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.

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

KeysWhereDoes
Arrows, Home, End, Page keysanywhereMove one cell, to the row's ends, by a page
Control+Home, Control+EndanywhereFirst cell, last cell (rows render on the way)
Enter, SpaceheaderSort ascending, descending, off
Shift+EnterheaderAdd the column to the sort
Shift+F10 or the Menu keyheaderColumn menu: sort, group, pin, move, hide
Alt+Left, Alt+RightheaderNarrower, wider (Option on a Mac)
Alt+Shift+Left, Alt+Shift+RightheaderMove the column
Enter, F2, or start typingeditable cellEdit; Enter saves, Escape cancels, Tab saves
Spaceyes/no cellFlip it
Spacefirst columnCheck the row
Entergroup rowOpen or close the group
Shift+Arrows, Shift+clickanywhereSelect a block
Shift+Space, Control+SpaceanywhereSelect the rows, the columns
Control+A, EscapeanywhereSelect all cells, clear the block
Control+C, Control+VanywhereCopy 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:

txt
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" with aria-rowcount and aria-rowindex, so screen readers know the full size while only visible rows render.
  • The primary sort column carries aria-sort. Group cells carry aria-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-invalid and 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's findable can lift this later.