Opens one row of a table and slides the detail for that row in underneath it, spanning the full width of the table. Reach for it wherever a row is a summary of something with more inside it: an orders or invoices table where the row is the order and the detail is the line items, a logs or audit-trail view where the detail is the stack trace or the raw payload, a billing history with a per-charge breakdown, an admin users or accounts table showing a person's roles and permissions, an incidents or alerts list showing the affected services, a deployments or CI runs table showing the failing step, a transactions table with the fee split, an API request log with the response body, a shipments table with the tracking events, a subscriptions table with the plan changes, and any table where the alternative is sending people to a detail page and back. Common asks it answers: "expandable table row react", "react table expandable rows", "table row expand to show details", "shadcn expandable table row", "shadcn table row detail", "shadcn collapsible table row", "shadcn data table expand row", "expandable rows without tanstack table", "tanstack table getCanExpand alternative", "react-table is too much for just expanding a row", "master detail table react", "drill down table row react", "table row accordion", "nested row table react", "sub row react table", "expand row to show more info", "detail row colspan react", "colspan for detail row", "what colspan should the detail row be", "table columns jump when a row expands", "column width changes when expanding row", "last column shrinks when row expands", "detail row breaks table layout", "zebra striping breaks with expanded rows", "nth-child striping counts the detail row", "striped table wrong rows shaded", "animate table row expand", "tr height transition not working", "cannot animate table row collapse", "collapse tr css does not work", "overflow hidden not working on tr", "height 0 on table row ignored", "aria-expanded table row", "accessible expandable table row", "expandable row screen reader", "chevron button has no accessible name", "icon button announces just button", "aria-controls expandable row", "row expander accessibility", "only one row open at a time table", "expand all rows table react", "collapse all rows", "keep expanded row after sorting", "expanded row state lost when table re-orders", "テーブル 行 展開 react", "行を開いて詳細を表示", "アコーディオン テーブル 行", "詳細行 colspan 列数". Official shadcn/ui has the styling for this and not the component, which is unusually direct evidence that the hole is real: its `TableRow` ships `has-aria-expanded:bg-muted/50`, so it already knows an expandable row is a thing people build on top of it — it simply does not build one. Measured across its whole registry today (63 items listed, 62 fetchable, `questionnaire` is indexed and 404s on both style tracks, 223,287 bytes of `files[].content` concatenated): `colSpan`, `colspan`, `aria-controls`, `expandable`, `subrow`, `getCanExpand`, `rowSpan` and `disclosure` are every one of them a zero hit, and the single `aria-expanded` hit is that one styling hook on `TableRow`. Its `table` is 2,468 bytes of styling wrappers. So every expandable row is hand-rolled, and four things go wrong each time — three of them measured in a browser rather than remembered, because the usual advice about each is either wrong or backwards. The first is the detail cell's `colSpan`, and the measurement inverts the instinct. On a 400px three-column table whose detail holds a long stack trace, the correct span of 3 gives column widths of 105.7 / 210.8 / 106.1. A span of 9 — far more columns than exist — gives 105.7 / 210.8 / 106.1: identical to the hundredth of a pixel, because the surplus becomes a phantom zero-width column that takes no space and draws nothing. A span of 2, one short, gives 141.1 / 281.5 / 26.6 — the last column, the one the detail never reaches, collapses from 100px to 26.6px. A span of 1 crushes two columns instead of one. So over-claiming is free and under-claiming is the whole bug, which means the safe fallback when the count is unknown is a large number rather than a small one, and 1 is the worst available answer rather than the cautious one. It is also why this is slippery in practice: the distortion is proportional to the detail's content width, so a short fixture looks perfect and the table bends only once a real stack trace arrives, in the column nobody touched. The second is that the detail row is not a row, and everything that counts rows disagrees. Measured on three rows with two open and both rows sharing one `<tbody>`, `tbody tr:nth-child(even)` does not shift the stripes by one — it lands them on both detail rows and no data row at all, so the table reads as having lost its striping and grown two shaded panels. This component is therefore a `<tbody>`, one per logical row, which is valid HTML and makes `tbody:nth-of-type(even) tr` correct again while shading each pair together. The same correction is what keeps a "3 of 12" counter, a roving tabindex walking rows with Up and Down, and the shift-click range of a row-selection checkbox from counting the panel as data. The third is that a table row cannot be collapsed, so the transition everyone writes is attached to the one element that cannot honour it — and it fails silently, with the row appearing instantly and the CSS sitting there looking correct. Asking each candidate to be zero-tall with `height: 0; overflow: hidden` around two lines of text: a `<tr>` measures 33px, a `<td>` measures 33px, a `<div>` inside the cell measures 0. `height` on a table row is a minimum rather than a size and `overflow` does not apply to `table-row` or `table-cell` at all, so that `<tr>` reports `height: 33px` and `overflow: hidden` together, the second simply having no effect. The detail content is therefore wrapped in a panel inside the cell — the only place a collapse can live — the cell itself carries no padding (padding cannot collapse either), and the panel carries `data-state="open"`, which is the attribute official's own collapsing components key their keyframes off, so `panelClassName="data-[state=open]:animate-accordion-down"` works in any project that has installed `accordion`. It is also where `grid-template-rows: 0fr → 1fr` works, the one transition that interpolates to the content's own height without measuring it in JavaScript. The fourth is the control. `aria-expanded` goes on a `<button>` inside the row and not on the `<tr>`: ARIA permits it on a row, so this is not a validity question — the point is that a row is not operable, takes no focus, answers no key and gives a screen reader no hint that pressing it does anything, so the state would be describing something nobody can press. Official settles it from the other side, since the registry's compiled stylesheet expands `has-aria-expanded:bg-muted/50` to `:has([aria-expanded=true])`, a descendant match — put the attribute on the row and that selector cannot see it, and the open row silently loses the one piece of styling official ships for this pattern. The button is also where the accessible name has to come from, which is the failure that survives every visual review: the control is a chevron with no text, so a table of forty of them is forty announcements of the word "button" with no way to tell which row is which. A `label` naming the row is required rather than optional here, the name becomes "Show details for invoice INV-2048", it flips to "Hide" when open, the chevron is `aria-hidden` so the same fact is not told three times, and `aria-controls` is set only while the detail cell actually exists, because a dangling reference offers a jump that lands nowhere. What you get: `TableRowExpander`, the `<tbody>` holding a row and its panel, which measures the real column count from the live table at press time so the detail row's first render already carries the true span — no first paint at the fallback and no frame in which the columns jump, and it keeps working when the number of columns is variable, as it is under `column-resizer`. `useRowExpansion` holds which rows are open for the whole table, keyed by id so a panel stays with its row when the table is re-sorted rather than with the row's old position, with `multiple: false` for the accordion case and `collapseAll()` for when a filter or the next page replaces the rows. `TableRowExpanderTrigger` is the chevron on its own, for when it has to sit inside a cell you are already rendering, and `trigger="start" | "end" | "none"` chooses whether this adds the chevron's column at all. `TableRowExpanderDetail` is the detail row by itself, `countTableColumns` and `resolveDetailColSpan` are the measuring and the span rule as plain functions for a table that renders its own rows. Everything stays in your `<table>`: the markup, the rows and the data are yours, and this works the same inside official's `<Table>` or a hand-written one. It renders nothing at all while closed, because a row that is present but empty is not cheaper than one that is absent and it leaves a hairline under every row in the table. Distinct from the neighbours: `tree-view` is a nested tree you walk with the keyboard, `nested-checkbox` is the tri-state roll-up of a parent over its children, and `sort-header`, `sticky-table-header` and `column-resizer` all act on the header rather than the body — none of them puts a panel under a row.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/table-row-expander.json"