Grouped table

Rows grouped under sticky headers that open and close, with every cell editable in place.

NameOwnerDraftStatus
TH
TH
TH
TH
AF
AF
AF
MA
MA
MA
AR
AR
AR
SB
SB
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/grouped-table.json
Usage
Columns say how each cell edits; rows are plain records. Group by any column but the first.
TSImport
import { GroupedTable } from "@/components/blocks/grouped-table";
TSExample
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)}
    />
  );
}
Composition
Anatomy of the GroupedTable block.
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 · onReorder
Settings
Every switch is a prop. Flip them to see what each one does; the snippet shows them all.
Projects
NameOwnerDraftStatus
TH
TH
TH
TH
AF
AF
AF
MA
MA
MA
AR
AR
AR
SB
SB
Custom cells and row actions
A column's `render` draws its cell (here, the name as a link). `rowActions` puts a menu on right-click; `rowClassName` styles single rows.
Projects
NameOwnerDraftStatus
TH
TH
TH
AF
AF
MA
AR
SB
Read-only columns
`readOnlyColumns` locks the columns you name while the rest still edit — here the name and owner change, the draft date and status don't.
Projects
NameOwnerDraftStatus
TH
Sep 4
Awaiting Updates
TH
Oct 15
Needs Feedback
TH
Nov 5
Scheduled
AF
Sep 10
Awaiting Updates
AF
Oct 14
Needs Feedback
MA
Nov 3
Scheduled
AR
Oct 22
Queued
SB
Oct 20
Queued
Nov 10
Scheduled
Loading
`loading` shows flat skeleton rows shaped like each column while the table has no rows — grouped or not, there are no fake group headers (`true` is 8 rows; a number sets the count). A response inside 300ms never flashes one, a skeleton that appears stays at least 500ms, and the groups then replace it with a cross-fade and blur. With rows already on screen `loading` changes nothing, and the empty message waits until loading is done. Pick a latency and a result, then Reload.
Loading Projects…
Projects
NameOwnerDraftStatus
Chip filters and header controls
`chipFilter` draws Data grid v3's chip bar under the header: All plus a chip per value of that column, counted after the search. `chipFilters` puts several groups in that one bar, split by hairlines — each its own one-of-many choice, counted after the search and the other groups; `all` leads a group with an All chip. `menuItems` go at the top of the ⋯ menu above Group by. `sortable` makes the headers sort each group. A column with `hidden` still groups and searches without being drawn.
Projects
David. Warrior, Worshipper, King
THTaylor Hughes
Awaiting Updates
Baptism Sunday Slides
THTaylor Hughes
Complete
Sermon Series: Rooted
THTaylor Hughes
Needs Feedback
Christmas Eve Invite Card
THTaylor Hughes
Scheduled
Child rows
`getChildRows` hangs rows under a row behind a caret (here, each project's deliverables). They stay under their parent whatever their own status, follow it through a sort, and never count toward the group. They are read-only and have no right-click menu unless you pass `onChildEdit` and `childRowActions`.
Projects
NameOwnerDraftStatus
SB
Sermon slides
SBSam Brooks
Oct 18
Complete
Social graphics
AFAva Foster
Oct 20
Queued
Bulletin insert
SBSam Brooks
Oct 21
Queued
MA
TH
TH
AF
Drag to reorder
`reorderableGroups` puts a grip on the rows of the groups it names. Drag with a mouse or finger, or Tab to a grip and press Space, the arrows, then Space. A row can't leave its group and its children move with it. The new order shows at once and `onReorder` reports it; when to save is up to you. Sort by a header and the grips hide.
Projects
TH
AF

Drag a Queued or Scheduled row by its grip.

Read-only, no header
`editable={false}` drops every editor; `hideHeader` drops the bar, so the column headings are the top of the card. Each works without the other. To lock only some columns, use `readOnlyColumns`.
NameOwnerDraftStatus
Sermon Series: Rooted
THTaylor Hughes
Oct 15
Needs Feedback
Christmas Eve Invite Card
THTaylor Hughes
Nov 5
Scheduled
Worship Night Lyrics
AFAva Foster
Oct 14
Needs Feedback
Advent Devotional
MAMia Alvarez
Nov 3
Scheduled
Men's Breakfast Flyer
ARAaron Reyes
Oct 22
Queued
Fall Kickoff Sunday
SBSam Brooks
Oct 20
Queued
Giving Tuesday Campaign
Unassigned
Nov 10
Scheduled
API Reference
Props exposed by GroupedTable, its columns and their options.

GroupedTable

PropTypeDefaultDescription
columnsColumn[]—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.
groupBystring | null—Controlled group column — any column but the first. `null` is one flat list.
defaultGroupBystring | nullfirst person or status columnUncontrolled starting group column.
onGroupByChange(key) => void—Fires when the ⋯ menu switches columns.
groupingMenubooleantrueThe ⋯ button in the header: Group by (every column but the first, or None), Expand all, Collapse all.
collapsedstring[]—Controlled closed groups, by group value.
defaultCollapsedstring[]—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`.
sortBystring—Column key each group is ordered by, ascending, empty last.
searchbooleanfalseA search in the header that unfolds from its icon; matches text columns.
searchPlaceholderstring"Search"Placeholder and label of the search.
hideHeaderbooleanfalseDrops 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.
editablebooleantrueFalse makes every cell read-only.
chipFilterstring—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 / onChipChangestring"all"The picked chip; controlled or not.
sortablebooleanfalseClick 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`.
menuItemsDropdownMenuItemSpec[]—Your own items at the top of the ⋯ menu, above the grouping ones. The menu shows when either is there.
aria-labelstring—The table's name for assistive tech when `title` is not a string (tabs, a picker).
readOnlyColumnsstring[]—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.
undoMovesbooleantrueA notification with Undo when an edit moves a row to another group.
nounstring"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.
hideEmptyGroupsbooleantrueFalse keeps a header for every option, even with no rows.
maxHeightnumber | string560The scroller's height cap. Headers stay pinned inside it.
batchSizenumber100Rows 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.
rowActionsLabelReactNode—Heading over the right-click menu.
rowClassName(row) => string | undefined—Extra classes per row — a row staged for deletion, say.
titleReactNode—Left of the header bar. A string is drawn as a heading; anything else (tabs, a picker) as given.
descriptionReactNode—Muted line under the title.
actionReactNode—Right of the header bar, after the search and the ⋯ menu.
loadingboolean | 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 / onExpandedChangestring[]—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.
childRowActionsLabelReactNode—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.
reorderableGroupsstring[]—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.
emptyTitlestring"No data found"Heading when there are no rows.
emptyDescriptionstring—Body copy when there are no rows.
classNamestring—Classes on the card.

Column

PropTypeDefaultDescription
keystring—The row field this column shows.
labelstring—Header text, menu label, group-by label.
type"text" | "person" | "status" | "date""text"How the cell draws and edits. Dates open `DatePicker variant="dropdown"`.
optionsOption[]—`person` and `status` only: the choices, in group order.
widthnumber220 first · 168 restWidth in px; a column's minimum when it grows.
grownumber1 first · 0 restShare of the spare width, like flex-grow. Give every column `grow: 1` to spread the room instead of handing it all to the first.
editablebooleantrueFalse 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).
sortablebooleantrueWith the table `sortable`: false keeps this header from sorting.
hiddenbooleanfalseGroups and searches without being drawn — for the column the rows are grouped by, which the group header already says.

Option

PropTypeDefaultDescription
valuestring—What the row stores.
labelstring—What the cell, menu and group header show.
colorstring—`status` only: the dot, any CSS colour.
srcstring—`person` only: the avatar image. Initials without it.