Tree Table
tree-table ui Hierarchical data table with expandable parent/child rows. Supports tree-structured data, column configuration, expand/collapse with per-level indent, row selection checkboxes, a loading overlay, and an empty state. Built on the existing table primitives.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/tree-table.json $ npx shadcn@latest add https://uipkge.dev/r/react/tree-table.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/tree-table.json $ bunx shadcn@latest add https://uipkge.dev/r/react/tree-table.json npx shadcn@latest add @uipkge-react/tree-table Installs to: components/ui/tree-table/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
data Tree-structured row data. | TreeTableRow[] | — | required |
columns Column configuration. | TreeTableColumn[] | — | required |
indent Indent per nesting level in pixels. Default 24. | number | — | optional |
defaultExpanded Expand all rows on mount. Default false. | boolean | — | optional |
selectable Show row selection checkboxes. Default false. | boolean | — | optional |
loading Loading state — shows a spinner overlay. Default false. | boolean | — | optional |
emptyText Empty state message. Default 'No data.'. | string | — | optional |
selected Controlled selected row ids (v-model:selected parity). | string[] | — | optional |
onSelectedChange Called when the selected set changes. | (ids: string[]) => void | — | optional |
onSelect Called when the selected set changes (alias of onSelectedChange). | (ids: string[]) => void | — | optional |
onExpand Called when a row is expanded or collapsed. | (id: string, expanded: boolean) => void | — | optional |
expandIcon Replace the default expand/collapse chevron. | (expanded: boolean) => React.ReactNode | — | optional |
renderCell Per-cell renderer keyed by column — mirrors the Vue `cell-<key>` slot. | (col: TreeTableColumn, row: TreeTableRow, depth: number) => React.ReactNode | — | optional |
className | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
FlatRow interface FlatRow {
row: TreeTableRow
depth: number
hasChildren: boolean
} npm dependencies
Files installed (3)
-
components/ui/tree-table/TreeTable.tsx 8.2 kB
'use client' import * as React from 'react' import { ChevronRight, FileBox } from 'lucide-react' import { cn } from '@/lib/utils' import { Checkbox } from '@/components/ui/checkbox' import { Spinner } from '@/components/ui/spinner' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import type { TreeTableColumn, TreeTableRow } from './types' export interface TreeTableProps { /** Tree-structured row data. */ data: TreeTableRow[] /** Column configuration. */ columns: TreeTableColumn[] /** Indent per nesting level in pixels. Default 24. */ indent?: number /** Expand all rows on mount. Default false. */ defaultExpanded?: boolean /** Show row selection checkboxes. Default false. */ selectable?: boolean /** Loading state — shows a spinner overlay. Default false. */ loading?: boolean /** Empty state message. Default 'No data.'. */ emptyText?: string /** Controlled selected row ids (v-model:selected parity). */ selected?: string[] /** Called when the selected set changes. */ onSelectedChange?: (ids: string[]) => void /** Called when the selected set changes (alias of onSelectedChange). */ onSelect?: (ids: string[]) => void /** Called when a row is expanded or collapsed. */ onExpand?: (id: string, expanded: boolean) => void /** Replace the default expand/collapse chevron. */ expandIcon?: (expanded: boolean) => React.ReactNode /** Per-cell renderer keyed by column — mirrors the Vue `cell-<key>` slot. */ renderCell?: (col: TreeTableColumn, row: TreeTableRow, depth: number) => React.ReactNode className?: string } interface FlatRow { row: TreeTableRow depth: number hasChildren: boolean } const TreeTable = React.forwardRef<HTMLDivElement, TreeTableProps>( ( { data, columns, indent = 24, defaultExpanded = false, selectable = false, loading = false, emptyText = 'No data.', selected, onSelectedChange, onSelect, onExpand, expandIcon, renderCell, className, }, ref, ) => { const [expandedSet, setExpandedSet] = React.useState<Set<string>>(new Set()) const [internalSelected, setInternalSelected] = React.useState<Set<string>>(new Set()) // Controlled `selected` wins when provided; otherwise fall back to internal state. const selectedSet = React.useMemo( () => (selected ? new Set(selected) : internalSelected), [selected, internalSelected], ) const expandAll = React.useCallback(() => { const next = new Set<string>() const walk = (rows: TreeTableRow[]) => { for (const row of rows) { if (row.children?.length) { next.add(row.id) walk(row.children) } } } walk(data) setExpandedSet(next) }, [data]) // Expand all on mount (and when data identity changes) if requested. React.useEffect(() => { if (defaultExpanded) expandAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultExpanded]) React.useEffect(() => { if (defaultExpanded) expandAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]) const flatRows = React.useMemo<FlatRow[]>(() => { const out: FlatRow[] = [] const walk = (rows: TreeTableRow[], depth: number) => { for (const row of rows) { const hasChildren = !!row.children?.length out.push({ row, depth, hasChildren }) if (hasChildren && expandedSet.has(row.id)) { walk(row.children, depth + 1) } } } walk(data, 0) return out }, [data, expandedSet]) const isExpanded = (id: string) => expandedSet.has(id) const isSelected = (id: string) => selectedSet.has(id) const toggleExpand = (row: TreeTableRow) => { setExpandedSet((prev) => { const next = new Set(prev) if (next.has(row.id)) next.delete(row.id) else next.add(row.id) onExpand?.(row.id, next.has(row.id)) return next }) } const toggleSelect = (row: TreeTableRow) => { const next = new Set(selectedSet) if (next.has(row.id)) next.delete(row.id) else next.add(row.id) const ids = [...next] if (selected === undefined) setInternalSelected(next) onSelectedChange?.(ids) onSelect?.(ids) } const isEmpty = flatRows.length === 0 return ( <div ref={ref} data-uipkge data-slot="tree-table" className={cn('relative w-full', className)}> <Table> <TableHeader> <TableRow> {selectable && ( <TableHead className="w-10"> <span className="sr-only">Select</span> </TableHead> )} {columns.map((col) => ( <TableHead key={col.key} className={cn(col.headerClass)}> {col.label} </TableHead> ))} </TableRow> </TableHeader> <TableBody> {flatRows.map((fr) => ( <TableRow key={fr.row.id} data-depth={fr.depth} data-expanded={fr.hasChildren ? isExpanded(fr.row.id) : undefined} data-selected={isSelected(fr.row.id) ? '' : undefined} > {selectable && ( <TableCell className="w-10"> <Checkbox checked={isSelected(fr.row.id)} onCheckedChange={() => toggleSelect(fr.row)} /> </TableCell> )} {columns.map((col, ci) => ( <TableCell key={col.key} className={cn(ci === 0 && 'font-medium', col.cellClass)}> <div className="flex items-center" style={ci === 0 ? { paddingLeft: `${fr.depth * indent}px` } : undefined} > {ci === 0 && fr.hasChildren && ( <button type="button" className="text-muted-foreground hover:bg-muted hover:text-foreground mr-1.5 flex size-5 shrink-0 items-center justify-center rounded" aria-label={isExpanded(fr.row.id) ? 'Collapse' : 'Expand'} aria-expanded={isExpanded(fr.row.id)} onClick={() => toggleExpand(fr.row)} > {expandIcon ? ( expandIcon(isExpanded(fr.row.id)) ) : ( <ChevronRight className={cn( 'size-4 transition-transform duration-150', isExpanded(fr.row.id) && 'rotate-90', )} /> )} </button> )} {ci === 0 && !fr.hasChildren && <span className="mr-1.5 w-5 shrink-0" />} {renderCell ? (renderCell(col, fr.row, fr.depth) ?? (col.render ? col.render(fr.row) : fr.row[col.key])) : col.render ? col.render(fr.row) : fr.row[col.key]} </div> </TableCell> ))} </TableRow> ))} {isEmpty && !loading && ( <TableRow> <TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="h-24 text-center"> <div className="text-muted-foreground flex flex-col items-center gap-2"> <FileBox className="size-8" /> <span className="text-sm">{emptyText}</span> </div> </TableCell> </TableRow> )} </TableBody> </Table> {loading && ( <div className="bg-background/60 absolute inset-0 flex items-center justify-center backdrop-blur-sm"> <Spinner size="lg" /> </div> )} </div> ) }, ) TreeTable.displayName = 'TreeTable' export { TreeTable } export type { TreeTableColumn, TreeTableRow } from './types' -
components/ui/tree-table/types.ts 0.6 kB
import type { ReactNode } from 'react' export interface TreeTableColumn<T = any> { /** Unique key matching a field on the row data. */ key: string /** Header label. */ label: string /** Optional class for the header cell. */ headerClass?: string /** Optional class for body cells in this column. */ cellClass?: string /** Custom cell renderer: receives the row and returns a React node. */ render?: (row: T) => ReactNode } export interface TreeTableRow<T = any> { /** Unique id for the row. */ id: string /** Row data fields keyed by column key. */ [key: string]: any /** Child rows. */ children?: TreeTableRow<T>[] } -
components/ui/tree-table/index.ts 0.1 kB
export { TreeTable, type TreeTableProps } from './TreeTable' export type { TreeTableColumn, TreeTableRow } from './types'
Raw manifest: https://uipkge.dev/r/react/tree-table.json