ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Tree Table

A hierarchical table for nested data — departments, teams, and employees — with expand/collapse, sorting, and a search box that keeps a matching row's ancestors visible.

npx shadcn add https://shadcn-table-library.vercel.app/r/tree-table.json
Name
Role
Status
Engineering
Department
Frontend
Team
Ava Thompson
Frontend LeadActive
Liam Chen
Frontend EngineerActive
Backend
Team
Sofia Patel
Backend LeadActive
Noah Garcia
Backend EngineerInactive
Design
Department
Product Design
Team
Mia Johnson
Design LeadActive
Ethan Kim
Product DesignerPending

How it works

1.Rows are nested, not flat

Each node carries its own children array (department → team → employee). getSubRows tells TanStack Table how to find a row's children, so the whole hierarchy is built from one recursive data structure instead of a flat list plus a parentId lookup.

src/components/tree/data-table.tsx
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSubRows: (row) => row.children,
getExpandedRowModel: getExpandedRowModel(),
// ...
})

2.Depth drives indentation

Every row TanStack produces carries a depth (0 for roots, 1 for their children, and so on). The Name cell reads row.depth directly into an inline padding-left — no manual recursion needed to indent nested rows.

src/components/tree/columns.tsx
cell: ({ row }) => (
<div
className="flex items-center gap-1.5"
style={{ paddingLeft: `${row.depth * 1.25}rem` }}
>
{/* expand toggle + row.original.name */}
</div>
)

3.Expansion is just table state

expanded is a normal piece of controlled state, the same shape as sorting or column filters elsewhere in this library. Each toggle button calls row.getToggleExpandedHandler() — clicking it flips that row in and out of expanded, and getExpandedRowModel recomputes which sub-rows are currently visible.

src/components/tree/columns.tsx
<button
type="button"
onClick={row.getToggleExpandedHandler()}
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
>
<ChevronRight className={row.getIsExpanded() ? 'rotate-90' : ''} />
</button>

4.Search keeps a matching row's ancestors visible

filterFromLeafRows: true changes how getFilteredRowModel treats the tree: a branch survives filtering if any descendant matches, not just the row itself. Without it, searching "Ava" would hide the whole Engineering → Frontend branch because the department and team rows don't contain that text.

src/components/tree/data-table.tsx
getFilteredRowModel: getFilteredRowModel(),
filterFromLeafRows: true,

5.Pagination is left out on purpose

TanStack's row model pipeline runs pagination after expansion, so slicing by row count would cut a parent's children off at a page boundary and split the tree mid-branch. This example only composes sorting, filtering, and expansion — add pagination only once you've decided how a split branch should behave.

src/components/tree/data-table.tsx
// getPaginationRowModel intentionally omitted —
// it runs after getExpandedRowModel and would
// paginate expanded child rows, not just roots.