Rows grouped under sticky headers that open and close, with every cell editable in place.
npx shadcn@latest add https://sdk-components.thesqd.com/r/grouped-table.jsonimport { GroupedTable } from "@/components/blocks/grouped-table";import { GroupedTable } from "@/components/blocks/grouped-table";
const statuses = [
{ value: "queued", label: "Queued", color: "#656f7d" },
{ value: "needs_feedback", label: "Needs Feedback", color: "#cc4291" },
{ value: "needs_update", label: "Awaiting Updates", color: "#e16b16" },
{ value: "complete", label: "Complete", color: "#08adff" },
];
const people = [
{ value: "th", label: "Taylor Hughes" },
{ value: "af", label: "Ava Foster" },
{ value: "ma", label: "Mia Alvarez" },
];
const rows = [
{ id: "1", name: "Fall Kickoff Sunday", owner: "af", draft: "2026-10-20", status: "queued" },
{ id: "2", name: "Worship Night Lyrics", owner: "af", draft: "2026-10-14", status: "needs_feedback" },
{ id: "3", name: "Youth Corn Maze", owner: "ma", draft: "2026-09-10", status: "needs_update" },
{ id: "4", name: "Starry Bethlehem", owner: null, draft: "2026-09-14", status: "needs_update" },
{ id: "5", name: "Prayer Guide", owner: "th", draft: "2026-10-06", status: "complete" },
];
export function Demo() {
return (
<GroupedTable
noun="project"
columns={[
{ key: "name", label: "Name" },
{ key: "owner", label: "Owner", type: "person", options: people, width: 180 },
{ key: "draft", label: "Draft", type: "date", width: 132 },
{ key: "status", label: "Status", type: "status", options: statuses, width: 184 },
]}
rows={rows}
sortBy="draft"
defaultCollapsed={["complete"]}
groupSummary={(group) => `${group.length} due`}
onEdit={(rowId, key, value) => console.log("save", rowId, key, value)}
/>
);
}GroupedTable (rows bucketed under sticky, collapsible group headers)
├── records-shell card-surface (the File browser / Data grid v3 shell)
│ ├── records-toolbar (unless hideHeader — then nothing sits above the column headings)
│ │ └── Input collapsible (search) · ⋯ DropdownMenu (group by · expand all · collapse all) · action
│ └── scroller (scroll-fade-head + scrollbar-minimal; the top fade starts under the sticky headers)
│ ├── column header row (sticky top-0)
│ └── per group: Collapsible
│ ├── CollapsibleTrigger (sticky under the column header: caret · dot or Avatar · label · Badge count · summary)
│ └── CollapsibleContent (one grid row per record)
│ ├── text cell → InvisibleInput (Enter saves, Esc reverts)
│ ├── person cell → Select searchable (type to filter, Avatars in the list)
│ ├── status cell → DropdownMenu of status dots
│ └── date cell → DatePicker variant="dropdown" (natural language, calendar on focus, quick picks)
│
├── groups person / status: one per option, in order · date: one per day · text: one per value, A→Z
│ empty or unknown values: "No {column}" last
├── a move to another group → opens that group, lights the row, notification with Undo
├── child rows (getChildRows) caret in the first cell · indented under the parent · guide line curving into each
└── reorderableGroups Sortable per group: grip in the left gutter · drag stays in the group · onReorderDrag a Queued or Scheduled row by its grip.
GroupedTable
| Prop | Type | Default | Description |
|---|---|---|---|
columns | Column[] | — | See Column below. The first leads each row, takes the spare width, and is never a group. |
rows | { id, …values }[] | — | Plain records keyed by column key. Values are read as text (numbers too); dates are `YYYY-MM-DD`. Edits write strings. |
groupBy | string | null | — | Controlled group column — any column but the first. `null` is one flat list. |
defaultGroupBy | string | null | first person or status column | Uncontrolled starting group column. |
onGroupByChange | (key) => void | — | Fires when the ⋯ menu switches columns. |
groupingMenu | boolean | true | The ⋯ button in the header: Group by (every column but the first, or None), Expand all, Collapse all. |
collapsed | string[] | — | Controlled closed groups, by group value. |
defaultCollapsed | string[] | — | Groups closed on first render. |
onCollapsedChange | (values) => void | — | Fires on every open and close — persist it to remember them. |
onGroupsChange | (values) => void | — | Every group's value whenever the set changes — for a Collapse all that lives outside the table, with `hideHeader`. |
sortBy | string | — | Column key each group is ordered by, ascending, empty last. |
search | boolean | false | A search in the header that unfolds from its icon; matches text columns. |
searchPlaceholder | string | "Search" | Placeholder and label of the search. |
hideHeader | boolean | false | Drops the header bar and everything in it — title, search, the ⋯ menu, action. The column headings are the top of the card. The table stays editable. |
editable | boolean | true | False makes every cell read-only. |
chipFilter | string | — | Column key the chip bar filters by — All plus one chip per value (its `options` in order, else A→Z), each counted after the search. |
chip / defaultChip / onChipChange | string | "all" | The picked chip; controlled or not. |
sortable | boolean | false | Click a column header to sort each group by it — ascending, descending, then back to the rows' own order (or `sortBy`). Empty values stay last. A column with `sortable: false` or no label doesn't sort. |
sort / defaultSort / onSortChange | { key, dir: 1 | -1 } | null | — | The header sort; controlled or not. |
chipFilters | { key, all?, value?, defaultValue?, onChange? }[] | — | Several chip groups in the one bar, split by hairlines. Each is a one-of-many choice over a column, counted after the search and the other groups. `all` (true or a label) leads with an All chip; without it a value is always picked. Takes the place of `chipFilter`. |
menuItems | DropdownMenuItemSpec[] | — | Your own items at the top of the ⋯ menu, above the grouping ones. The menu shows when either is there. |
aria-label | string | — | The table's name for assistive tech when `title` is not a string (tabs, a picker). |
readOnlyColumns | string[] | — | Column keys that stay read-only while the rest of the table edits — `["draft", "status"]`. |
onEdit | (rowId, key, value) => void | — | One call per edit, after the table already shows it. |
undoMoves | boolean | true | A notification with Undo when an edit moves a row to another group. |
noun | string | "row" | What a row is, for the counts: "project" → "4 projects". |
groupSummary | (rows) => ReactNode | — | Right side of each group header — a rollup of its rows. |
hideEmptyGroups | boolean | true | False keeps a header for every option, even with no rows. |
maxHeight | number | string | 560 | The scroller's height cap. Headers stay pinned inside it. |
batchSize | number | 100 | Rows a group draws at a time. The rest keep their height, so the scroll length never jumps; scrolling into them draws down to where you are. |
rowActions | { label, icon?, variant?, onClick(row) }[] | — | A right-click menu on every row. `variant: "destructive"` tints an item red. |
rowActionsLabel | ReactNode | — | Heading over the right-click menu. |
rowClassName | (row) => string | undefined | — | Extra classes per row — a row staged for deletion, say. |
title | ReactNode | — | Left of the header bar. A string is drawn as a heading; anything else (tabs, a picker) as given. |
description | ReactNode | — | Muted line under the title. |
action | ReactNode | — | Right of the header bar, after the search and the ⋯ menu. |
loading | boolean | number | — | First-load state. While true AND `rows` is empty, the body shows flat skeleton rows shaped like each column — a pill for a status, a dot and a bar for a person, a short bar for a date (`true` = 8 rows; a number sets the count). A response faster than 300ms never shows a skeleton; one that appears stays at least 500ms, then the rows replace it with a cross-fade and blur. With rows already on screen it changes nothing, so a refetch keeps the stale rows readable. The empty message never shows while loading. |
getChildRows | (row) => Row[] | undefined | — | Rows drawn under a row, behind a caret in its first cell (no caret when it returns nothing). They sit under their parent whatever the group column says, follow it through a sort, never count toward the group or the empty state, and are indented with a guide line from the caret. Their ids must not repeat a row's. |
expanded / defaultExpanded / onExpandedChange | string[] | — | Ids of the rows whose children show; controlled or not. → opens and ← closes a row from its first cell; ← on a child goes to its parent. |
renderChildName | (child, parent) => ReactNode | — | Draws a child's first cell. Default: its first-column value. |
childRowActions | { label, icon?, variant?, onClick(child) }[] | — | Right-click menu on child rows. Without it they have none. |
childRowActionsLabel | ReactNode | — | Heading over the child rows' right-click menu. |
onChildEdit | (childId, parentId, key, value) => void | — | Makes child cells edit (the same columns rows edit); one call per edit, after the table shows it. Without it children are read-only. |
reorderableGroups | string[] | — | Group values whose rows drag to reorder by a grip at their left — mouse, touch, or Tab to the grip then Space, arrows, Space. A row stays in its group and takes its children along. These groups keep the rows' own order (`sortBy` passes them by); the grips hide while a header sort is on. |
onReorder | (group, ids) => void | — | The group's row ids in their new order, after the table already shows it. The table only reports it: saving is yours. A new `rows` replaces the order. |
emptyTitle | string | "No data found" | Heading when there are no rows. |
emptyDescription | string | — | Body copy when there are no rows. |
className | string | — | Classes on the card. |
Column
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | — | The row field this column shows. |
label | string | — | Header text, menu label, group-by label. |
type | "text" | "person" | "status" | "date" | "text" | How the cell draws and edits. Dates open `DatePicker variant="dropdown"`. |
options | Option[] | — | `person` and `status` only: the choices, in group order. |
width | number | 220 first · 168 rest | Width in px; a column's minimum when it grows. |
grow | number | 1 first · 0 rest | Share of the spare width, like flex-grow. Give every column `grow: 1` to spread the room instead of handing it all to the first. |
editable | boolean | true | False locks this column while the rest edits. |
render | (row) => ReactNode | — | Draws the cell yourself — a link, a thumbnail, a formatted number. Display-only; grouping and search still read the raw value. |
sortValue | (row) => string | number | Date | null | — | What the header sorts by when the shown text isn't it — the bytes behind "4.2 MB", the time behind "Oct 20". Default: the value (a person or status column, its option's label). |
sortable | boolean | true | With the table `sortable`: false keeps this header from sorting. |
hidden | boolean | false | Groups and searches without being drawn — for the column the rows are grouped by, which the group header already says. |
Option
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | What the row stores. |
label | string | — | What the cell, menu and group header show. |
color | string | — | `status` only: the dot, any CSS colour. |
src | string | — | `person` only: the avatar image. Initials without it. |