Data grid v3

The spreadsheet: a rectangular selection you can type into, copy, cut, paste, fill and clear, with the Excel key map and one write path.

Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
Rows12 rows
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/data-grid-v3.json
Usage
Pass `columns` and `rows`. A column's `type` picks the cell renderer, the editor and the default clipboard round trip. Both props default to the built-in demo set, so `<DataGridV3 />` still renders with nothing passed.
TSImport
import { DataGridV3 } from "@/components/ui/data-grid-v3";
TSExample
import {
  DataGridV3,
  type DataGridV3Column,
  type DataGridV3Row,
} from "@/components/ui/data-grid-v3";

const COLUMNS: DataGridV3Column[] = [
  // readOnly is copyable but never written: a paste over it is rejected
  { id: "sku", header: "SKU", width: 120, readOnly: true, mono: true },
  // width is the starting width; minWidth/maxWidth bound a drag-resize
  { id: "name", header: "Product", width: 220, minWidth: 140 },
  {
    id: "deliverables",
    header: "Deliverables",
    type: "chips",
    width: 210,
    // the vocabulary; a row's value is an array of these values
    options: [
      { value: "video", label: "Video", color: "purple" },
      { value: "graphics", label: "Graphics", color: "brand" },
      { value: "print", label: "Print", color: "amber" },
      { value: "social", label: "Social", color: "coral" },
    ],
    // how many chips fit before the rest collapse into "+N"
    maxChips: 2,
  },
  {
    id: "team",
    header: "Team",
    type: "user",
    width: 210,
    // the roster the picker offers; a row's value is an array of ids
    users: [
      { id: "aj", name: "Alex Johnson" },
      { id: "sc", name: "Sarah Chen" },
      { id: "mr", name: "Michael Rodriguez" },
    ],
  },
  // stored as minutes; shows 1h 30m / 45m / 4h 00m
  { id: "timeEstimate", header: "Time Estimate", type: "duration", width: 150 },
  { id: "restock", header: "Restock", type: "date", width: 150 },
  { id: "stock", header: "Stock", type: "number", width: 90 },
  { id: "price", header: "Price", type: "currency", width: 100 },
  {
    id: "status",
    header: "Status",
    type: "status",
    width: 130,
    options: [
      { value: "active", label: "Active", color: "success" },
      { value: "archived", label: "Archived", color: "muted" },
    ],
  },
];

const ROWS: DataGridV3Row[] = [
  { id: "1", sku: "SQD-1001", name: "Sermon bumper pack", deliverables: ["video", "graphics"], timeEstimate: 90, team: ["aj", "sc"], restock: "2026-09-14", stock: 24, price: 149, status: "active" },
  { id: "2", sku: "SQD-1002", name: "Worship lyric template", deliverables: ["graphics"], timeEstimate: 45, team: ["mr"], restock: "2026-09-22", stock: 8, price: 79, status: "active" },
  { id: "3", sku: "SQD-1003", name: "Kids ministry title set", deliverables: [], timeEstimate: 240, team: [], restock: "", stock: 0, price: 119, status: "archived" },
];

export function Example() {
  return (
    <DataGridV3
      columns={COLUMNS}
      rows={ROWS}
      onEdit={(edits) => console.log(edits)}
    />
  );
}
Composition
The grid, its scroller, and the editor and menu it owns.
TXTAnatomy
DataGridV3
└─ .records-shell            outer card (or full-bleed when `fill`)
   ├─ .records-toolbar       title · selection count · note · search · Filters · ⋯ · Add row
   │  ├─ .records-filters     the filter-builder block, in a popover sized to the grid
   │  └─ ⋯ menu               Columns (hideable: false rows pinned) · Group by · Export
   ├─ .records-chipbar        band holding a <ToggleChips> row — one-click
   │                          status chips with live counts (chipFilter)
   ├─ .records-subrow         revealed child rows (expandable + row.subItems)
   ├─ .records-scroll        the x/y scroller, and the key surface
   │  └─ .records-table.records-table-v3
   │     ├─ thead            headers; the selected columns light up
   │     └─ tbody            cells: selection tint, focus ring, corner fill square
   ├─ inline editor          one flush textarea over the focused cell
   ├─ context menu           cell group (edit/copy/cut/paste/clear) + row group
   └─ .records-pager         rows-per-page · row + selection count · pages
                             (pagination="scroll": the count alone, rows append on scroll)

layout="board" — and every grouped grid — is the same grid in the board view's shape
├─ .records-band.card-surface   the toolbar (and the chip bar) as a band of its own
├─ .records-scroll              the x/y scroller, now holding cards
│  ├─ .records-band-head        the column headers, stuck to the top
│  └─ .records-group            one card per group, or one card for every row
│     ├─ .records-group-head    dot · title · count pill · caret (collapses the group)
│     └─ tbody                  the group's rows: same columns, same selection
└─ .records-pager.records-band  only when it has something to do: a pager, more
                                rows to reach, or a selection to count — the
                                toolbar's pill already says how many rows
Spreadsheet editing
Click a cell, drag or shift-click to extend the rectangle — a drag selects cells, never text. Drag a header edge to resize that column, double-click it to reset. Type to replace, Enter or F2 to open the flush editor, Escape to revert. ⌘C / ⌘X / ⌘V move a whole rectangle as Excel-quoted TSV, ⌘A takes the sheet, Delete clears. Drag the corner square to fill the cells you sweep — a pattern taller or wider than one cell repeats.
Products
SKU
Product
Deliverables
Team
Time Estimate
Restock
Stock
Price
Status
SQD-1001Sermon bumper pack1h 30m
24$149.00
SQD-1002Worship lyric template45m
8$79.00
SQD-1003Kids ministry title set4h 00m
0$119.00
SQD-1004Announcement loop1h 15m
46$59.00
SQD-1005Baptism social kit30m
12$89.00
SQD-1006Christmas series brand8h 00m
3$249.00
SQD-1007Volunteer welcome deck2h 30m
17$69.00
SQD-1008Giving campaign bundle5h 00m
31$199.00
SQD-1009Podcast cover system1h 00m
5$99.00
SQD-1010Easter motion pack7h 00m
22$279.00
SQD-1011Small group graphics1h 45m
14$89.00
SQD-1012Stage design plans6h 00m
2$329.00
12 rows
Search and filters
`searchable` puts a search field in the toolbar — it matches the text every cell actually shows, so a status reads as its label and a person cell as their name. `filters` puts the [`filter-builder`](/blocks/filter-builder) block behind a Filters button: one row per clause, drag-sortable, with `and` / `or` shared per group and one level of nesting for `A and (B or C)`. The grid feeds it a field per column and keeps the evaluation, so a clause still tests the text a cell shows. Both narrow the spreadsheet itself rather than hiding rows under it, so the selection, the clipboard and the pager all address the filtered set.
Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
Rows12 rows
Grouped, in the board's shape
`groupBy` puts the rows under one heading per value of a column, and a grouped grid takes the shape of the [board view](/blocks/board-view): the toolbar becomes the same standalone band the board wears, with a count pill beside the title, and each group is a card of its own under a header band that stays put, with the column's dot, the group's name, its count and a caret that collapses it. A `status` column's groups follow its vocabulary, the way the board's columns follow the statuses; anything outside it comes after, alphabetical, and rows with no value last. The rows are reordered into group order rather than merely headed, so the selection rectangle, the keyboard and the clipboard walk the table as it is drawn. A collapsed group's rows leave the list the way a filter's do. The toolbar's ⋯ menu holds everything about the table rather than its rows: Columns, Group by (None plus every `groupable` column) and Export, as CSV of the text the cells show or JSON of the stored values, of every row the search, filters and chips leave.
Deliverables12 deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-M3PZ6LBaptism social kit30m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-V2ZX8PEaster motion pack7h 00m
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-N5GF6WSmall group graphics1h 45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-Q4TB9SGiving campaign bundle5h 00m
DEL-H9KR4CStage design plans6h 00m
DEL-T7XM31Worship lyric template45m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-L7MD3KPodcast cover system1h 00m
The board's shape
`layout="board"` asks for the shape without the grouping: the toolbar as a band of its own, the table as one card under it, the count pill beside the title. For a page that shows a table and a board of the same list, this is what makes the two read as one system rather than two components that happen to share a toolbar.
Deliverables12 deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
DEL-L7MD3KPodcast cover system1h 00m
DEL-V2ZX8PEaster motion pack7h 00m
DEL-N5GF6WSmall group graphics1h 45m
DEL-H9KR4CStage design plans6h 00m
Chip filters
`chipFilter` takes the id of a `status` column and puts its vocabulary above the table as one-click chips — All plus one per option, each with the option's dot and a live count. The counts describe what the chips are choosing between, so they reflect the search and clause filters but not the chip already picked. Filtering by a single column is the common case, and a chip is one click where the clause editor is four.
Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
DEL-L7MD3KPodcast cover system1h 00m
DEL-V2ZX8PEaster motion pack7h 00m
DEL-N5GF6WSmall group graphics1h 45m
DEL-H9KR4CStage design plans6h 00m
12 rows
Expandable rows
A row carrying `subItems` gets a caret that reveals them; a row without gets a disabled one, so the first column doesn't jump from row to row. Sub-rows render the same columns as plain text and sit OUTSIDE the selection rectangle and the clipboard — they are detail, not data you sweep. A `<tr>` can't be tweened, so each cell tweens its own content and they rise together, and a group stays mounted after its first reveal so collapsing animates shut instead of unmounting mid-flight. Shown with both gridline props off: `rowLines={false}` drops the rules between rows (the header keeps its own — separating headers from data is a different job) and `columnLines={false}` drops the ones between columns.
Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
Rows12 rows
Expanded panels
`renderExpanded` reveals one panel across every column instead of sub-rows — a checklist, a chart, a detail block. The grid knows nothing about what goes in it: it hands you the row and renders what you return, so the pipeline here is a separate `<ChecklistPanel />` block rather than a grid feature. Each of its items sits on one line — box, label, then its properties (owner, estimate, due date) beside them — with any SOP behind a trailing button that opens a popover, so a panel of six steps stays scannable and the procedure is there when asked for rather than in the way. Returning `null` leaves that row's caret disabled. It takes precedence over `subItems`, so one grid can do both and decide per row. The panel is pinned to the scroller's left edge rather than scrolling with the columns: it is not a row of values, and sliding it under the frozen column showed it straight through, since that column has no cell in a panel row to paint over it. A `render` on any column draws that cell yourself — here a rollup reading the checklist's ticks, which works because passing `onToggle` makes the panel controlled.
Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
Checklist
DEL-9F4KQ2Sermon bumper pack1h 30m
x
1/4
DEL-T7XM31Worship lyric template45m
x
3/4
DEL-B2NR8VKids ministry title set4h 00m
x
2/4
DEL-K5QW7DAnnouncement loop1h 15m
x
3/4
DEL-M3PZ6LBaptism social kit30m
x
0/4
DEL-X8CV4TChristmas series brand8h 00m
x
1/4
Rows12 rows
Rows on scroll
`pagination="scroll"` reaches the rows past `pageSize` by scrolling rather than paging: the first batch renders, the next is appended as the scroller nears its end, and the pager with its rows-per-page select gives way to a plain count. When the first batch does not overflow the frame the grid keeps appending until it does, so a tall frame never shows eight rows and blank space under them. For a grid that fills its frame the frame is the page, and this is the mode that makes sense; shown here in a fixed-height container with `fill`.
Deliverables
ID
Deliverable
Assignee
Time Estimate
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
8 of 48 rows
Column picker
`columnPicker` puts Columns in the toolbar's ⋯ menu: one checkbox per column, a count of how many are hidden, and a Show all. A column with `hideable: false` is listed but pinned, for the column a row is read by, so a table can never be reduced to values with nothing to attach them to. `defaultHiddenColumns` starts some hidden; `hiddenColumns` with `onHiddenColumnsChange` makes the list controlled, which is where a reader's choice gets persisted. The grid only ever hides what it was given. A column a viewer must not see should not be in `columns` at all: leaving it out is the caller's decision, hiding it is the reader's.
Deliverables
ID
Deliverable
Assignee
Draft Date
Status
DEL-9F4KQ2Sermon bumper pack
DEL-T7XM31Worship lyric template
DEL-B2NR8VKids ministry title set
DEL-K5QW7DAnnouncement loop
DEL-M3PZ6LBaptism social kit
DEL-X8CV4TChristmas series brand
DEL-R6HJ2NVolunteer welcome deck
DEL-Q4TB9SGiving campaign bundle
Rows12 rows
Your own columns
`title` and `icon` name the grid in the toolbar — any node for the glyph, `false` to drop it. `text`, `number`, `currency`, `duration`, `date`, `status`, `chips`, `user`, `link` and `actions` each pick the renderer, the editor and the default parse/format. Override either to own the round trip — here `hours` leaves as `12h` and comes back from `12h` or `12`. Returning `undefined` from `parse` REJECTS the write, so a bad number keeps the old value instead of blanking it. A column's `onClick` makes its cells clickable — a drill-down or a route push, as on `line` here — without dressing them as links: a pointer cursor and an underline on hover, nothing more.
Project budget
Line item
Owner
Hours
Rate
Phase
Alex Johnson12h$95.00
Sarah Chen8h$110.00
David Kim24h$85.00
Emma Wilson6h$95.00
Michael Rodriguez18h$120.00
5 rows
Live updates
`flashChanges` lights a cell up for a moment when its value changes under the grid — a live feed, a background sync, another person's edit landing in your table. The grid keeps the previous rows by `id` and compares them column by column with the next `rows`, so a re-sort, a new row or a re-render with the same values never flashes; only a value that actually moved does. `true` is 2 seconds, a number is the duration in ms, and a cell that changes again mid-fade lights up again. Reduced motion holds a still outline for the same time instead. Read-only grids only: an editable grid keeps its own copy of the rows, so it would be flashing a cell that still shows the old value. Here one row's stock changes every few seconds.
Stock
SKU
Product
Deliverables
Team
Time Estimate
Restock
Stock
Price
Status
SQD-1001Sermon bumper packVideoGraphics
AJSC
1h 30mSeptember 14, 202624$149.00Active
SQD-1002Worship lyric templateGraphicsMR45mSeptember 22, 20268$79.00Active
SQD-1003Kids ministry title setGraphicsPrint+1
EWDKAJ
4h 00mOctober 2, 20260$119.00Archived
SQD-1004Announcement loopVideoSC1h 15mSeptember 8, 202646$59.00Active
SQD-1005Baptism social kit——30m—12$89.00Active
SQD-1006Christmas series brandVideoGraphics+3
DKMR
8h 00mNovember 1, 20263$249.00Active
6 rows
Loading and empty
`loading` shows skeleton rows shaped like each column — a pill for a status, a dot and a bar for a person, a short right-aligned bar for a number — while the grid has no rows (`true` is 8 rows, capped at `pageSize`; a number sets the count). A response inside 300ms never flashes one, a skeleton that appears stays at least 500ms, and the rows then replace it with a cross-fade and blur. With rows already on screen `loading` changes nothing, so a refetch keeps the stale rows readable. While it is up the count reads `Loading…`, Add row is disabled and Export is hidden. `emptyState` is what the body shows once loading is done and `rows` is empty — only for no data at all; a search or filter that leaves zero rows keeps the grid's own behaviour. Pass `rows={data ?? []}`: an undefined `rows` falls back to the demo rows. Pick a latency and a result, then Reload.
Loading Deliverables…
Deliverables
SKU
Product
Deliverables
Team
Time Estimate
Restock
Stock
Price
Status
RowsLoading…
Your own header
`hideHeader` drops the toolbar and the chip bar and keeps the table — and nothing about the grid's behaviour goes with them. Every control the toolbar had writes to state you can own: `search` / `onSearchChange`, `filters` / `onFiltersChange`, `chip` / `onChipChange`, `sort` / `onSortChange`, `hiddenColumns` / `onHiddenColumnsChange` and `groupBy` / `onGroupByChange`, each controlled when you pass a value and internal when you don't. `onViewChange` is the other half: it hands back what the toolbar knew — the rows the reader sees in their current order, the total, the chip options with their live counts, the `filterFields` for a [`filter-builder`](/blocks/filter-builder), the visible / hideable / groupable column lists, and the `exportRows` and `addRow` actions — so your header renders the same counts and offers the same menus without re-deriving any of it. Everything above the grid here is plain SDK primitives wired to those props; the grid is searching, filtering, chipping and sorting exactly as it does with its own toolbar on.
0 of 0
DEL-9F4KQ2Sermon bumper pack1h 30m
DEL-T7XM31Worship lyric template45m
DEL-B2NR8VKids ministry title set4h 00m
DEL-K5QW7DAnnouncement loop1h 15m
DEL-M3PZ6LBaptism social kit30m
DEL-X8CV4TChristmas series brand8h 00m
DEL-R6HJ2NVolunteer welcome deck2h 30m
DEL-Q4TB9SGiving campaign bundle5h 00m
Rows12 rows
Minimal chrome
`title={false}` drops the toolbar, and `contextMenu`, `fillHandle` and `editMarks` each switch off their affordance. Selection, keys and the clipboard stay — they are the grid, not chrome.
SKU
Product
Deliverables
Team
Time Estimate
Restock
Stock
Price
Status
SQD-1001Sermon bumper pack1h 30m
24$149.00
SQD-1002Worship lyric template45m
8$79.00
SQD-1003Kids ministry title set4h 00m
0$119.00
SQD-1004Announcement loop1h 15m
46$59.00
SQD-1005Baptism social kit30m
12$89.00
SQD-1006Christmas series brand8h 00m
3$249.00
SQD-1007Volunteer welcome deck2h 30m
17$69.00
SQD-1008Giving campaign bundle5h 00m
31$199.00
SQD-1009Podcast cover system1h 00m
5$99.00
SQD-1010Easter motion pack7h 00m
22$279.00
SQD-1011Small group graphics1h 45m
14$89.00
SQD-1012Stage design plans6h 00m
2$329.00
12 rows
Full bleed
`fill` drops the card border, radius and shadow and stretches to the parent's height — for when the grid owns the whole region. Shown here in a fixed-height container.
Products
SKU
Product
Deliverables
Team
Time Estimate
Restock
Stock
Price
Status
SQD-1001Sermon bumper pack1h 30m
24$149.00
SQD-1002Worship lyric template45m
8$79.00
SQD-1003Kids ministry title set4h 00m
0$119.00
SQD-1004Announcement loop1h 15m
46$59.00
SQD-1005Baptism social kit30m
12$89.00
SQD-1006Christmas series brand8h 00m
3$249.00
SQD-1007Volunteer welcome deck2h 30m
17$69.00
SQD-1008Giving campaign bundle5h 00m
31$199.00
SQD-1009Podcast cover system1h 00m
5$99.00
SQD-1010Easter motion pack7h 00m
22$279.00
SQD-1011Small group graphics1h 45m
14$89.00
SQD-1012Stage design plans6h 00m
2$329.00
Rows12 rows
How it works
How the spreadsheet behaves.

One write path

Every editor, paste, fill, cut and clear funnels through a single `applyEdits` call, so `rows` stays the only owner of the data and the edited-cell set can't drift from it. `onEdit` fires once per commit with the whole batch.

parse rejects, it doesn't blank

A column's `parse` returns the stored value — or `undefined` to reject the write. A paste of 40 cells over a read-only column and three bad numbers writes what it can and reports the rest as rejected, rather than clearing them.

The clipboard is real TSV

Copy emits CRLF rows and Excel-quoted fields, so a value carrying a tab, a newline or a quote survives a round trip through Sheets. Paste runs the inverse parser and lands the block with its top-left corner on the selection's, clipped to the grid.

Fill repeats the pattern

Dragging the corner square over a taller or wider region cycles the source rectangle through it, the way Sheets does. Filling across columns re-parses through the target column, so text survives a type change.

One render per resize

A column drag moves a single guideline element by transform and commits the width once, on drop. Writing state per frame would re-render every visible row and hitch the drag; a custom property on an ancestor would invalidate style for every cell in the table.

The selection outlives the page

Ranges are addressed by index into the full row list, not the visible page, so paging never renumbers a selection — and arrowing off the bottom edge turns the page instead of dead-ending.

Scroll fills the frame first

In scroll mode the first batch is `pageSize` rows, but a batch that does not overflow the scroller leaves nothing to scroll and no way to reach the rest. So the grid keeps appending batches until the scroller overflows, then waits for the reader to near the end. A search, a filter, a chip or a sort resets the count, since they change what the rows are; an edit does not, since it changes a cell and not the list.

A group reorders the rows, it doesn't just head them

Grouping sorts the filtered rows into group order and draws each group as its own card, so row 7 of the selection is the seventh row you can see, whatever group it sits in: the rectangle, the arrow keys, the clipboard and the fill handle all walk the table as drawn. Collapsing a group removes its rows from that list the way a filter does, so what you cannot see you cannot select or paste over. Export ignores the collapse: it is a way of reading the table, not a way of leaving rows out of it.

The header is only a renderer

Every control in the toolbar writes to state the caller can own: `search`, `filters`, `chip`, `sort`, `hiddenColumns` and `groupBy` all take a value and hand back a change. `onViewChange` hands out the other half — the visible rows, the counts, the chip options with their live counts, the filter vocabulary and the column lists — so an external header can render exactly what the built-in one does. `hideHeader` then drops the band entirely and the grid keeps every bit of its behaviour: nothing about searching, filtering or sorting lives in the toolbar, only its controls do.

The picker hides, it doesn't gate

Hiding a column is a reader's preference, so it lives in the grid and can be persisted through `onHiddenColumnsChange`. Whether a column exists for a viewer at all is the caller's decision, made by leaving it out of `columns`: a hidden column is still one click from visible, and its values are still in the rows.

Hover spells the cell out

A cell truncates to one line, so hovering a CLIPPED one shows its full value in a tooltip after the SDK's standard 1s delay — long enough that sweeping the pointer across a grid stays quiet. A cell showing all of its text has nothing to add and stays quiet. Skipped over the controls, where a tip would sit on top of the thing you are about to click, and over `actions` columns, whose buttons carry their own labels. It is ONE tooltip for the grid, moved over the hovered cell, not one mounted per cell.

Durations are minutes

A `type: "duration"` cell stores plain minutes and shows `1h 30m` / `45m` / `4h 00m` — minutes alone under an hour, and always two digits once there is an hour, so a column of them lines up. It reads back `1h 30m`, `1h`, `90m`, `1:30`, `1.5h` or a bare `90`, which is what makes a paste from a timesheet land.

Chips for object types

A `type: "chips"` cell holds an array of `options` values — deliverables, formats, ministries — rendered as badges with the rest collapsed into `+N` (`maxChips`). Clicking opens a checkable list of the vocabulary; the clipboard carries the labels comma-joined, and a pasted label the vocabulary doesn't know is dropped rather than stored.

Person cells are Avatar groups

A `type: "user"` cell hands the row's ids straight to `<Avatar people max={3} version="v2-initials" />` — the overlapping cluster, per-person tooltips and the `+N` overflow chip all come from the avatar primitive rather than being re-drawn here.

A clickable column is not a link

`onClick` on a column makes its cells clickable while they still read as data — no link colour, just a pointer cursor and an underline on hover. The click is claimed but the press is not, so the cell still selects and copies; inline editing is off for that column, since one click cannot both open an editor and go somewhere. Use `type: "link"` instead when the value IS a URL and you want it to look like one.

Columns with their own control

Status and person columns render a real select and a real picker. Enter on such a cell clicks that control rather than opening a textarea, so the keyboard and the mouse do the same thing.

API Reference
Props for the `<DataGridV3>` component.
PropTypeDefaultDescription
columnsDataGridV3Column[]demo columnsColumn definitions: `id`, `header`, `type`, `width`, `minWidth`, `maxWidth`, `align`, `readOnly`, `mono`, `hideable`, `headerHidden`, `options`, `maxChips`, `users`, `actions`, `render`, `parse`, `format`, `clearValue`, `onClick`.
rowsDataGridV3Row[]demo rowsRow data keyed by column id, each with an `id`. Re-seeds only when the set of row ids changes, so an inline literal doesn't wipe edits every render.
loadingboolean | number—First-load state. While true AND the grid has no rows, the body shows skeleton rows shaped like each column (`true` = 8 rows, capped at `pageSize`; a number sets the count). With rows already on screen it changes nothing — a refetch keeps the stale rows readable. A response faster than 300ms never shows a skeleton; one that appears stays at least 500ms. Pass `rows={data ?? []}`: an undefined `rows` falls back to the demo rows.
emptyStateReact.ReactNode—What the body shows once loading is done and `rows` is empty — e.g. `<Empty title="No projects yet" />`. Only for no data at all; a search or filter that leaves zero rows keeps today's behaviour.
titlestring | false"Products"Toolbar heading. `false` drops the toolbar entirely.
iconReact.ReactNode | falsetable iconThe glyph beside the heading — pass any node (a Phosphor icon, a coloured dot) to replace it, or `false` to drop it and leave the title alone.
pageSizenumber—Rows per page. Omit for one un-paginated list. In scroll mode, the size of each batch.
pageSizeOptionsnumber[][10, 25, 50, 100]Choices in the rows-per-page picker.
pagination"pages" | "scroll""pages"How the rows past `pageSize` are reached. `pages` is the pager and rows-per-page select in the footer. `scroll` renders the first `pageSize` rows and appends the next batch as the scroller nears its end, with the pager replaced by the count alone; a first batch that does not overflow the frame keeps growing until it does. Without a `pageSize`, a scroll batch is 50 rows. Either way the DOM holds only the rows in view plus an overscan — the body is windowed, with spacer rows keeping the scroller its true height — so a page of a thousand rows costs what a screenful does. Rows have to be one height for that, which an `expandable` grid's are not; it draws every row.
columnPickerbooleanfalseOffer Columns in the toolbar's ⋯ menu: one checkbox per column, to hide and show them. A column with `hideable: false` is listed but pinned. The grid only ever hides what it was given: a column a viewer must not see should not be in `columns` at all.
groupPickerbooleanfalseOffer Group by in the toolbar's ⋯ menu: None plus every `groupable` column.
groupBystring | null—The column the rows are grouped under, controlled. Pair with `onGroupByChange`. Grouping renders in the board's shape whatever `layout` says.
defaultGroupBystring—The column the rows start grouped under, uncontrolled.
onGroupByChange(columnId: string | null) => void—Fires with the new column id, or null for none, whenever the menu changes it.
columns[].dateFormat"long" | "short""long"A `date` column's display. `long` is "September 14, 2026", the form the cell's own picker shows. `short` is a board card's "Sep 14", and keeps the year only when the date is not in the current one — for a table standing beside a board. Typing and pasting take either. A date column that is `readOnly` renders as text, so `short` is what it shows; an editable one IS the picker.
columns[].headerHiddenbooleanfalseDraw this column's header cell empty — a thumbnail column, a row of icon actions, anything a heading would only label redundantly. `header` is still required and is still what the column picker lists it as, so a nameless column can still be hidden and shown by name.
columns[].groupablebooleantrue for status columnsWhether Group by offers this column. A `status` column's vocabulary is an order the groups can follow; a column of free text groups into as many groups as it has values, so it is off until you opt it in.
layout"card" | "board""card"`card` is the one bordered card with the toolbar, table and pager inside it. `board` is the shape the [board view](/blocks/board-view) has: the toolbar as a band of its own, the table as a card under it, a count pill beside the title. A grouped grid is always `board`, one card per group.
countLabel(count: number) => stringn rowsThe words in a count pill: the toolbar's total in the board's shape, and every group heading's count. A table of projects says `n projects`.
exportableboolean | ("csv" | "json")[]falseOffer Export in the toolbar's ⋯ menu. `true` is CSV and JSON; name the formats to offer one. Exports what the reader sees: every row the search, filters and chips leave, in the current sort, in the visible columns, collapsed groups included. CSV carries the text each cell shows; JSON carries the stored values.
exportNamestringthe titleThe download's file name, without its extension.
hiddenColumnsstring[]—Hidden column ids, controlled. Pair with `onHiddenColumnsChange`.
defaultHiddenColumnsstring[][]Hidden column ids on first render, uncontrolled.
onHiddenColumnsChange(hidden: string[]) => void—Fires with the new hidden list whenever the picker changes it: the place to persist a reader's choice.
columns[].hideablebooleantrueWhether the picker may hide this column. Pass `false` for the column a row is read by, the id or the name, so a table can never be reduced to values with nothing to attach them to.
chipFilterstring—Id of a `status` column to expose as one-click chips above the table — All plus one per option with a live count. Composes with `searchable` and `filters`: the chips narrow whatever those leave.
searchableboolean | stringfalseShow a search field in the toolbar, matching the text every cell shows. Pass a string to set its placeholder.
filtersDataGridV3Filter[] | DataGridV3FilterGroup—Initial filters, and the switch for the Filters button — passing anything (`[]` included) shows it, omitting it hides filtering. A flat clause list (`{ id, fieldId, operator, value }`) is combined with `and`; pass a group for `A and (B or C)`. The panel itself is the [`filter-builder`](/blocks/filter-builder) block, installed alongside v3: v3 supplies a field per column (its operators and its candidate values) and evaluates the tree against the text each cell shows.
searchstring—The search text, controlled. Pair with `onSearchChange` to drive the grid from a search box of your own — it searches exactly as the built-in field does.
defaultSearchstring—The search text on first render, uncontrolled.
onSearchChange(search: string) => void—Fires with the new text whenever the search changes.
onFiltersChange(filters: DataGridV3FilterGroup) => void—Fires with the whole tree whenever the panel changes it. Pair with `filters` to drive filtering from a `FilterBuilder` of your own.
chipstring—The chosen chip, controlled. `"all"` for none.
defaultChipstring"all"The chip chosen on first render, uncontrolled.
onChipChange(chip: string) => void—Fires with the new chip value whenever the chip bar changes it.
sortDataGridV3Sort—The sort, controlled: `{ id, dir }` or `null` for the data's own order.
defaultSortDataGridV3Sort—The sort on first render, uncontrolled.
onSortChange(sort: DataGridV3Sort) => void—Fires with the new sort whenever a header click cycles it.
hideHeaderbooleanfalseDrop the whole header band — toolbar and chip bar — and keep the table. The grid still searches, filters, chips, groups and sorts; it just draws none of the controls, so you can render your own header and drive it through the props above. The table's own column header row stays. The toolbar's transient note ("Pasted 12 cells") and the selected-cell count go with the band — a headless grid's own header owns that line.
onViewChange(view: DataGridV3View) => void—Fires whenever the view changes, with everything the toolbar knows: `rows` (what the reader sees, in order), `total`, `chips` with live counts, `filterFields` for a `FilterBuilder`, `columns`, `hideableColumns`, `groupableColumns`, and the `exportRows` / `addRow` actions. The read side of the header API.
addRowbooleantrueShow the toolbar's Add row button. The new row lands on whatever page it falls on, with the selection already on it.
toolbarActionsReactNode—Your own buttons at the end of the toolbar, after Add row — a New project, an Invite. Drawn only when the toolbar is (not with `title={false}` or `hideHeader`).
columns[].actionsDataGridV3Action[]—For a `type: "actions"` column: the icon buttons every row gets. Each is `{ label, icon, onClick(row) }` plus optional `active(row)` / `activeIcon` for a toggle and `disabled(row)`. The column holds no data — it copies as an empty field and rejects every paste — so the rows need no key for it.
columnWidthnumber200Starting width for any column that doesn't declare its own `width`. A column's own `width` still wins, and a drag-resize overrides both.
sortablebooleanfalseClick a column header to sort by it — ascending, descending, then back to the data's own order. Sorts on what the cell shows (so a status column keeps its label order and a person column is alphabetical) except numbers and dates, which sort on the stored value. Off by default: in a spreadsheet the row order is data, and a stray header click that reshuffles it under a selection is worse than a missing feature.
expandablebooleanfalseGive rows carrying `subItems` a caret that reveals them. Sub-rows are read-only detail: the same columns rendered as text, outside the selection rectangle and the clipboard.
columns[].render(row: DataGridV3Row) => React.ReactNode—Draw a cell yourself — a rollup, a progress bar, a sparkline. Read-only by nature: the cell has no editor, and the clipboard still goes through `format` / `parse`, so a rendered column copies as its text.
renderExpanded(row: DataGridV3Row) => React.ReactNode—What a row reveals instead of sub-rows: one panel spanning every column. Return `null` for a row with nothing to show. Takes precedence over `subItems` when it returns something.
rowLinesbooleantrueRules between rows. `false` drops them; the header keeps its own rule either way.
columnLinesbooleantrueRules between columns. `false` drops them, headers included.
cellSelectionbooleantrueThe spreadsheet layer: the cell rectangle, its keyboard map and the clipboard. `false` makes it a plain table — clicks select nothing, the cell cursor and focus ring go, and the scroller leaves the tab order. Pair it with `readOnly` for a catalogue people only read.
readOnlybooleanfalseLock the grid: no editor, paste, fill, cut, clear or row add/duplicate/delete, and the status/person/chips cells render as plain values instead of pickers. Selection and copy stay, so a locked table is still readable and still lands on the clipboard. Per-column `readOnly` locks one column instead.
flashChangesboolean | numberfalseLight a cell up when its value changes between one `rows` and the next (matched by row `id` and column) — for live data. `true` is 2000ms, a number is the duration in ms; a second change mid-fade restarts it, and reduced motion gets a still outline instead. New rows, re-sorts and unchanged values never flash. Read-only grids only (`readOnly`).
resizablebooleantrueDrag a header's right edge to resize that column — a guideline tracks the edge and one render lands at the drop. Double-click the edge to reset it to the column's declared `width`. Honours the column's `minWidth` / `maxWidth`.
fillHandlebooleantrueDrag the selection's corner square to fill the cells you sweep.
contextMenubooleantrueRight-click a cell for the cell group (edit, copy, cut, paste, clear) and the row group (duplicate, copy row, delete row). The gesture acts on the selection when the press lands inside it, otherwise on that one cell.
editMarksbooleantrueFlag cells edited in this session in their corner and mark rows added in this session on the frozen column's edge. Presentational only.
fillbooleanfalseDrop the card chrome and stretch to the parent's height.
onEdit(edits: DataGridV3Edit[]) => void—Fires once per commit with every cell the write touched — one call for a paste of 40 cells, not 40.
onRowAdd(row: DataGridV3Row) => void—Fires with the new row after Add row or Duplicate row.
onRowDelete(rowId: string) => void—Fires after Delete row.