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.jsonName | Role | Status |
|---|---|---|
Engineering | Department | — |
Frontend | Team | — |
Ava Thompson | Frontend Lead | Active |
Liam Chen | Frontend Engineer | Active |
Backend | Team | — |
Sofia Patel | Backend Lead | Active |
Noah Garcia | Backend Engineer | Inactive |
Design | Department | — |
Product Design | Team | — |
Mia Johnson | Design Lead | Active |
Ethan Kim | Product Designer | Pending |
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.
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.
cell: ({ row }) => (<divclassName="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.
<buttontype="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.
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.
// getPaginationRowModel intentionally omitted —// it runs after getExpandedRowModel and would// paginate expanded child rows, not just roots.