Expandable table

Simple table's look for rows that open onto anything — a checklist, a form, a feed.

Deliverables
Emma Taylor
Oct 14
In progress
  1. Concept sketches
  2. Two directions
  3. Final files
Liam Johnson
Oct 12
Waiting on you
Social pack
Sophia Brown
Oct 20
Not started
Daniel Wu
Oct 2
Done
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/expandable-table.json
Usage
Columns and rows like Simple table; cells and the open row are nodes you draw.
TSImport
import { ExpandableTable } from "@/components/blocks/expandable-table";
TSExample
import { Badge } from "@/components/ui/badge";
import { ExpandableTable } from "@/components/blocks/expandable-table";

const owners: Record<string, string> = { "1": "Emma Taylor", "4": "Daniel Wu" };
const dues: Record<string, string> = { "1": "2026-10-14", "4": "2026-10-02" };

export function Demo() {
  return (
    <ExpandableTable
      nameLabel="Deliverables"
      columns={[
        { key: "owner", label: "Owner", width: 140, align: "left", sortValue: (r) => owners[r.id] },
        { key: "due", label: "Due", width: 120, sortValue: (r) => dues[r.id] },
        { key: "status", label: "Status", width: 150, hideable: false },
      ]}
      rows={[
        {
          id: "1",
          name: "Sermon series graphic",
          expandable: true,
          summary: "1/3 steps",
          cells: { owner: "Emma Taylor", due: "Oct 14", status: <Badge color="info">In progress</Badge> },
        },
        {
          id: "4",
          name: "Bulletin cover",
          muted: true,
          cells: { owner: "Daniel Wu", due: "Oct 2", status: <Badge color="success">Done</Badge> },
        },
      ]}
      defaultExpandedIds={["1"]}
      renderExpanded={(row) => <StepList deliverableId={row.id} />}
    />
  );
}
Composition
Anatomy of the ExpandableTable component.
ExpandableTable                   (Simple table's card, rows that open onto anything)
├── card-surface wrapper
│   ├── header                    (only when title / description / action / columnPicker)
│   │   ├── title + description
│   │   └── "Columns" menu + action
│   └── horizontal scroller       (scroll-fade-x)
│       └── <table>
│           ├── <thead>           (name column · one sortable <th> per shown column)
│           └── <tbody>           (per row: <tr> with caret + name + summary + cells,
│                                  then, while open, one full-width <tr> with renderExpanded)
│
├── columns={[{ key, label, width?, align?, sortValue?, hideable?, className? }]}
├── rows={[{ id, name, cells?, summary?, expandable?, muted? }]}
└── props: nameLabel · renderExpanded · defaultExpandedIds · reveal
           sort · defaultSort · onSortChange
           hiddenColumns · defaultHiddenColumns · onHiddenColumnsChange · columnPicker
           title · description · action · emptyTitle · emptyDescription
Column picker and sorting
`columnPicker` adds a Columns menu; columns with `sortValue` sort on click. Control `sort` and `hiddenColumns` to remember them per person.
Deliverables
Deliverables
Oct 2
Done
Oct 12
Waiting on you
Oct 14
In progress
Social pack
Oct 20
Not started
Empty
`emptyTitle` and `emptyDescription` fill the body when there are no rows.
Deliverables
No deliverables yet
They show up here once the project is planned.
API Reference
Props exposed by the ExpandableTable component.

ExpandableTable

PropTypeDefaultDescription
columnsColumn[]—The columns after the name — see Column below.
rowsRow[]—See Row below.
nameLabelstring"Name"Header of the name column.
renderExpanded(row) => ReactNode—What an open row shows, in one full-width cell. Called only while open.
defaultExpandedIdsstring[]—Rows open on first render.
reveal{ id: string; at: number } | null—Opens that row and scrolls it into view. A new `at` lands again on the same row.
sort{ key, direction } | null—Controlled sort. Header clicks cycle ascending → descending → off.
defaultSort{ key, direction } | null—Uncontrolled starting sort.
onSortChange(sort) => void—Fires on every header click — persist it to remember the sort.
hiddenColumnsstring[]—Controlled hidden column keys.
defaultHiddenColumnsstring[]—Uncontrolled starting hidden keys.
onHiddenColumnsChange(keys) => void—Fires when the picker hides or shows a column.
columnPickerbooleanfalseA Columns menu in the header for every `hideable` column.
titleReactNode—Card header title.
descriptionReactNode—Muted line under the title.
actionReactNode—Right side of the header row.
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—Matches a key in each row's `cells`.
labelstring—Header text, also the picker label.
widthnumber | string120Fixed width. The name column takes the rest.
align"left" | "center" | "right""right"Cell and header alignment.
sortValue(row) => string | number | null—Makes the column sortable. Empty values sort last both ways.
hideablebooleantrueFalse keeps the column out of the picker.
classNamestring—Classes on each body cell.

Row

PropTypeDefaultDescription
idstring—Stable key; also what `reveal` and `defaultExpandedIds` name.
nameReactNode—The first column.
cellsRecord<string, ReactNode>—Column key → cell. A missing key draws nothing.
summaryReactNode—Small muted text after the name of a row that opens.
expandableboolean—Draws the caret; needs `renderExpanded`.
mutedboolean—Finished row: the name recedes, cells keep their colour.