UIPackage
Menu

Tree Table

tree-table ui
Edit on GitHub

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

$ npx shadcn@latest add https://uipkge.dev/r/react/tree-table.json
Named registry: 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