Master-Detail Table
Expand an order to reveal its shipping address and a nested sub-table of line items — a full-width detail row rendered directly beneath the row that owns it.
npx shadcn add https://shadcn-table-library.vercel.app/r/master-detail-table.json| Order | Customer | Date | Status | Total | |
|---|---|---|---|---|---|
| ORD-1001 | Ava Thompson | 2026-06-02 | Delivered | $154.97 | |
| ORD-1002 | Liam Chen | 2026-06-04 | Processing | $42.50 | |
| ORD-1003 | Sofia Patel | 2026-06-05 | Delivered | $310.00 | |
| ORD-1004 | Noah Garcia | 2026-06-06 | Cancelled | $18.99 |
How it works
1.Expansion is a Set of ids, not TanStack's row-expansion feature
The Tree Table and Grouped Table use getExpandedRowModel because their detail content is really more rows of the same shape (subRows). An order's detail panel isn't another row — it's arbitrary markup — so a plain useState<Set<string>> tracking which order ids are open is all that's needed, no row model feature required.
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())function toggleRow(id: string) {setExpandedRows((prev) => {const next = new Set(prev)next.has(id) ? next.delete(id) : next.add(id)return next})}
2.The expander column is built inline, not imported statically
Every other column comes from the static columns array in columns.tsx, same as the rest of this library. The expander column can't live there — its cell needs to read expandedRows and call toggleRow — so it's assembled with useMemo inside the table component and prepended to the imported columns each time expandedRows changes.
const allColumns = useMemo<ColumnDef<Order, any>[]>(() => [{ id: 'expander', header: () => null, cell: ({ row }) => (/* toggle button */) },...columns,],[columns, expandedRows],)
3.The detail area is a second table row, not a special cell
After each master TableRow, the body conditionally renders a second TableRow with one TableCell spanning colSpan={allColumns.length}. That keeps the header's column grid and every collapsed row's column grid identical — only the expanded row's markup breaks out of the per-column layout, and only for that one row.
<TableRow data-state={isExpanded ? 'expanded' : undefined}>{/* normal per-column cells */}</TableRow>{isExpanded ? (<TableRow><TableCell colSpan={allColumns.length} className="bg-muted/30 p-4"><OrderDetail order={row.original} /></TableCell></TableRow>) : null}
4.The detail panel can hold anything, including another table
OrderDetail renders a small shipping-address panel next to a fully separate, nested <Table> of line items. A <table> nested inside a <td> is ordinary, valid HTML — unlike putting a non-table element directly inside <table> (the mistake that broke the Reorderable Table's DndContext earlier), nesting one table inside another table's cell is completely fine, so the same Table/TableHeader/TableBody components get reused one level down.
<TableCell>{/* master row cells */}</TableCell>{/* ...inside the expanded row's single cell: */}<Table><TableHeader>{/* Product / Qty / Price / Subtotal */}</TableHeader><TableBody>{/* order.items.map(...) */}</TableBody></Table>
5.Collapsed orders never compute their detail content
OrderDetail (and each line item's qty × price subtotal) only renders inside the isExpanded ? ... : null branch — for the three collapsed orders on the page, nothing about their items or shipping address is rendered or computed until the user actually asks to see it.
{isExpanded ? (<TableRow>{/* <OrderDetail order={row.original} /> */}</TableRow>) : null}