The spreadsheet: a rectangular selection you can type into, copy, cut, paste, fill and clear, with the Excel key map and one write path.
This field is required
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m |
npx shadcn@latest add https://sdk-components.thesqd.com/r/data-grid-v3.jsonimport { DataGridV3 } from "@/components/ui/data-grid-v3";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)}
/>
);
}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 rowsSKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
| SQD-1001 | Sermon bumper pack | 1h 30m | 24 | $149.00 | ||||
| SQD-1002 | Worship lyric template | 45m | 8 | $79.00 | ||||
| SQD-1003 | Kids ministry title set | 4h 00m | 0 | $119.00 | ||||
| SQD-1004 | Announcement loop | 1h 15m | 46 | $59.00 | ||||
| SQD-1005 | Baptism social kit | 30m | 12 | $89.00 | ||||
| SQD-1006 | Christmas series brand | 8h 00m | 3 | $249.00 | ||||
| SQD-1007 | Volunteer welcome deck | 2h 30m | 17 | $69.00 | ||||
| SQD-1008 | Giving campaign bundle | 5h 00m | 31 | $199.00 | ||||
| SQD-1009 | Podcast cover system | 1h 00m | 5 | $99.00 | ||||
| SQD-1010 | Easter motion pack | 7h 00m | 22 | $279.00 | ||||
| SQD-1011 | Small group graphics | 1h 45m | 14 | $89.00 | ||||
| SQD-1012 | Stage design plans | 6h 00m | 2 | $329.00 |
This field is required
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m |
This field is required
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-V2ZX8P | Easter motion pack | 7h 00m |
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-N5GF6W | Small group graphics | 1h 45m |
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m | |||
| DEL-H9KR4C | Stage design plans | 6h 00m |
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-L7MD3K | Podcast cover system | 1h 00m |
This field is required
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m | |||
| DEL-L7MD3K | Podcast cover system | 1h 00m | |||
| DEL-V2ZX8P | Easter motion pack | 7h 00m | |||
| DEL-N5GF6W | Small group graphics | 1h 45m | |||
| DEL-H9KR4C | Stage design plans | 6h 00m |
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m | |||
| DEL-L7MD3K | Podcast cover system | 1h 00m | |||
| DEL-V2ZX8P | Easter motion pack | 7h 00m | |||
| DEL-N5GF6W | Small group graphics | 1h 45m | |||
| DEL-H9KR4C | Stage design plans | 6h 00m |
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m |
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status | Checklist |
|---|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | x | |||
| DEL-T7XM31 | Worship lyric template | 45m | x | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | x | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | x | |||
| DEL-M3PZ6L | Baptism social kit | 30m | x | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | x |
SKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
| SQD-1001 | Sermon bumper pack | 1h 30m | 24 | $149.00 | ||||
| SQD-1002 | Worship lyric template | 45m | 8 | $79.00 | ||||
| SQD-1003 | Kids ministry title set | 4h 00m | 0 | $119.00 | ||||
| SQD-1004 | Announcement loop | 1h 15m | 46 | $59.00 | ||||
| SQD-1005 | Baptism social kit | 30m | 12 | $89.00 |
ID | Deliverable | Assignee | Time Estimate | Draft Date | Status |
|---|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m |
ID | Deliverable | Assignee | Draft Date | Status |
|---|---|---|---|---|
| DEL-9F4KQ2 | Sermon bumper pack | |||
| DEL-T7XM31 | Worship lyric template | |||
| DEL-B2NR8V | Kids ministry title set | |||
| DEL-K5QW7D | Announcement loop | |||
| DEL-M3PZ6L | Baptism social kit | |||
| DEL-X8CV4T | Christmas series brand | |||
| DEL-R6HJ2N | Volunteer welcome deck | |||
| DEL-Q4TB9S | Giving campaign bundle |
Line item | Owner | Hours | Rate | Phase |
|---|---|---|---|---|
| Alex Johnson | 12h | $95.00 | ||
| Sarah Chen | 8h | $110.00 | ||
| David Kim | 24h | $85.00 | ||
| Emma Wilson | 6h | $95.00 | ||
| Michael Rodriguez | 18h | $120.00 |
SKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
| SQD-1001 | Sermon bumper pack | VideoGraphics | AJSC | 1h 30m | September 14, 2026 | 24 | $149.00 | Active |
| SQD-1002 | Worship lyric template | Graphics | MR | 45m | September 22, 2026 | 8 | $79.00 | Active |
| SQD-1003 | Kids ministry title set | GraphicsPrint+1 | EWDKAJ | 4h 00m | October 2, 2026 | 0 | $119.00 | Archived |
| SQD-1004 | Announcement loop | Video | SC | 1h 15m | September 8, 2026 | 46 | $59.00 | Active |
| SQD-1005 | Baptism social kit | — | — | 30m | — | 12 | $89.00 | Active |
| SQD-1006 | Christmas series brand | VideoGraphics+3 | DKMR | 8h 00m | November 1, 2026 | 3 | $249.00 | Active |
SKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
This field is required
| DEL-9F4KQ2 | Sermon bumper pack | 1h 30m | |||
| DEL-T7XM31 | Worship lyric template | 45m | |||
| DEL-B2NR8V | Kids ministry title set | 4h 00m | |||
| DEL-K5QW7D | Announcement loop | 1h 15m | |||
| DEL-M3PZ6L | Baptism social kit | 30m | |||
| DEL-X8CV4T | Christmas series brand | 8h 00m | |||
| DEL-R6HJ2N | Volunteer welcome deck | 2h 30m | |||
| DEL-Q4TB9S | Giving campaign bundle | 5h 00m |
SKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
| SQD-1001 | Sermon bumper pack | 1h 30m | 24 | $149.00 | ||||
| SQD-1002 | Worship lyric template | 45m | 8 | $79.00 | ||||
| SQD-1003 | Kids ministry title set | 4h 00m | 0 | $119.00 | ||||
| SQD-1004 | Announcement loop | 1h 15m | 46 | $59.00 | ||||
| SQD-1005 | Baptism social kit | 30m | 12 | $89.00 | ||||
| SQD-1006 | Christmas series brand | 8h 00m | 3 | $249.00 | ||||
| SQD-1007 | Volunteer welcome deck | 2h 30m | 17 | $69.00 | ||||
| SQD-1008 | Giving campaign bundle | 5h 00m | 31 | $199.00 | ||||
| SQD-1009 | Podcast cover system | 1h 00m | 5 | $99.00 | ||||
| SQD-1010 | Easter motion pack | 7h 00m | 22 | $279.00 | ||||
| SQD-1011 | Small group graphics | 1h 45m | 14 | $89.00 | ||||
| SQD-1012 | Stage design plans | 6h 00m | 2 | $329.00 |
SKU | Product | Deliverables | Team | Time Estimate | Restock | Stock | Price | Status |
|---|---|---|---|---|---|---|---|---|
| SQD-1001 | Sermon bumper pack | 1h 30m | 24 | $149.00 | ||||
| SQD-1002 | Worship lyric template | 45m | 8 | $79.00 | ||||
| SQD-1003 | Kids ministry title set | 4h 00m | 0 | $119.00 | ||||
| SQD-1004 | Announcement loop | 1h 15m | 46 | $59.00 | ||||
| SQD-1005 | Baptism social kit | 30m | 12 | $89.00 | ||||
| SQD-1006 | Christmas series brand | 8h 00m | 3 | $249.00 | ||||
| SQD-1007 | Volunteer welcome deck | 2h 30m | 17 | $69.00 | ||||
| SQD-1008 | Giving campaign bundle | 5h 00m | 31 | $199.00 | ||||
| SQD-1009 | Podcast cover system | 1h 00m | 5 | $99.00 | ||||
| SQD-1010 | Easter motion pack | 7h 00m | 22 | $279.00 | ||||
| SQD-1011 | Small group graphics | 1h 45m | 14 | $89.00 | ||||
| SQD-1012 | Stage design plans | 6h 00m | 2 | $329.00 |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
columns | DataGridV3Column[] | demo columns | Column definitions: `id`, `header`, `type`, `width`, `minWidth`, `maxWidth`, `align`, `readOnly`, `mono`, `hideable`, `headerHidden`, `options`, `maxChips`, `users`, `actions`, `render`, `parse`, `format`, `clearValue`, `onClick`. |
rows | DataGridV3Row[] | demo rows | Row 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. |
loading | boolean | 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. |
emptyState | React.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. |
title | string | false | "Products" | Toolbar heading. `false` drops the toolbar entirely. |
icon | React.ReactNode | false | table icon | The 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. |
pageSize | number | — | Rows per page. Omit for one un-paginated list. In scroll mode, the size of each batch. |
pageSizeOptions | number[] | [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. |
columnPicker | boolean | false | Offer 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. |
groupPicker | boolean | false | Offer Group by in the toolbar's ⋯ menu: None plus every `groupable` column. |
groupBy | string | null | — | The column the rows are grouped under, controlled. Pair with `onGroupByChange`. Grouping renders in the board's shape whatever `layout` says. |
defaultGroupBy | string | — | 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[].headerHidden | boolean | false | Draw 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[].groupable | boolean | true for status columns | Whether 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) => string | n rows | The 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`. |
exportable | boolean | ("csv" | "json")[] | false | Offer 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. |
exportName | string | the title | The download's file name, without its extension. |
hiddenColumns | string[] | — | Hidden column ids, controlled. Pair with `onHiddenColumnsChange`. |
defaultHiddenColumns | string[] | [] | 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[].hideable | boolean | true | Whether 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. |
chipFilter | string | — | 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. |
searchable | boolean | string | false | Show a search field in the toolbar, matching the text every cell shows. Pass a string to set its placeholder. |
filters | DataGridV3Filter[] | 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. |
search | string | — | 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. |
defaultSearch | string | — | 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. |
chip | string | — | The chosen chip, controlled. `"all"` for none. |
defaultChip | string | "all" | The chip chosen on first render, uncontrolled. |
onChipChange | (chip: string) => void | — | Fires with the new chip value whenever the chip bar changes it. |
sort | DataGridV3Sort | — | The sort, controlled: `{ id, dir }` or `null` for the data's own order. |
defaultSort | DataGridV3Sort | — | The sort on first render, uncontrolled. |
onSortChange | (sort: DataGridV3Sort) => void | — | Fires with the new sort whenever a header click cycles it. |
hideHeader | boolean | false | Drop 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. |
addRow | boolean | true | Show the toolbar's Add row button. The new row lands on whatever page it falls on, with the selection already on it. |
toolbarActions | ReactNode | — | 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[].actions | DataGridV3Action[] | — | 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. |
columnWidth | number | 200 | Starting width for any column that doesn't declare its own `width`. A column's own `width` still wins, and a drag-resize overrides both. |
sortable | boolean | false | Click 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. |
expandable | boolean | false | Give 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. |
rowLines | boolean | true | Rules between rows. `false` drops them; the header keeps its own rule either way. |
columnLines | boolean | true | Rules between columns. `false` drops them, headers included. |
cellSelection | boolean | true | The 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. |
readOnly | boolean | false | Lock 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. |
flashChanges | boolean | number | false | Light 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`). |
resizable | boolean | true | Drag 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`. |
fillHandle | boolean | true | Drag the selection's corner square to fill the cells you sweep. |
contextMenu | boolean | true | Right-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. |
editMarks | boolean | true | Flag cells edited in this session in their corner and mark rows added in this session on the frozen column's edge. Presentational only. |
fill | boolean | false | Drop 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. |