Skip to the content

ComponentsData

Tree table

A table of nested rows people open, sort, check and page through: organisations, their teams and their people.

Import

import { TreeTable } from "@booleanpress/ui/tree-table"

Usage

Pass the rows as tree nodes, with your values in data, and the columns; the first column holds the tree. Name the table with aria-label or aria-labelledby.

import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string }

const rows: TreeNode<Member>[] = [
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation" }, children: [{ id: "ravi", label: "Ravi Patel", data: { role: "Engineer" } }] },
]

const columns: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role", sortable: true },
]

export function Members() {
  return <TreeTable aria-label="Members" nodes={rows} columns={columns} />
}

A cell shows node.data[column.id] (the first column falls back to the node's label), or what the column's cell draws. A sortable column sorts the rows by clicking its heading, ascending, descending, then not at all; siblings sort among themselves at every level, by sortValue or the cell's value, numbers by size and text in the provider's locale. The sort is uncontrolled with defaultSort, or controlled with sort and onSortChange.

The open rows and the chosen rows are arrays of ids, as in Tree: defaultExpanded or expanded with onExpandedChange; defaultSelected or selected with onSelectedChange. selectionMode is none, single, multiple or checkbox; with checkboxes, checking a row checks its branch, a partly checked row shows a dash, and the first heading checks or unchecks every row.

pageSize pages the top-level rows, their children staying with them, with pagination below (page and onPageChange to control it). scrollHeight scrolls the rows under a header that stays in place. size sets the cell padding (the provider's controlSize by default), gridlines draws every cell's edge and striped tints every other row. loadChildren, loading and empty work as in Tree; expandAllButton puts a button in the first heading that opens or closes every row.

It is drawn with the library's Table parts and shares the Tree's state and keys, so it needs no table library.

Examples

Basic

Organisations, their teams and their people; the first column opens a row into its children.

import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      {
        id: "nw-support",
        label: "Support",
        data: { role: "Team", tickets: 9 },
        children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)],
      },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  {
    id: "acme",
    label: "Acme Hosting",
    data: { role: "Organisation", tickets: 7 },
    children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] }],
  },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableBasic() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={ORGANISATIONS}
      columns={COLUMNS}
      defaultExpanded={["northwind", "nw-support"]}
      className="max-w-3xl"
    />
  )
}

Sizes

size sets the cell padding: sm 6 ร— 8 px, default 8 ร— 14 px, lg 15 ร— 20 px.

import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@booleanpress/ui/toggle-group"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 } },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 } },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 } }] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableSizes() {
  const [size, setSize] = useState<"sm" | "default" | "lg">("default")

  return (
    <div className="flex w-full max-w-3xl flex-col items-center gap-4">
      <ToggleGroup type="single" variant="outline" value={size} onValueChange={(next) => next && setSize(next as typeof size)} aria-label="Row size">
        <ToggleGroupItem value="sm">Small</ToggleGroupItem>
        <ToggleGroupItem value="default">Default</ToggleGroupItem>
        <ToggleGroupItem value="lg">Large</ToggleGroupItem>
      </ToggleGroup>
      <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} size={size} defaultExpanded={["northwind"]} />
    </div>
  )
}

Gridlines

gridlines draws an edge round every cell; icons and status tags come from the nodes and a column's cell.

import { Building2Icon, UserIcon, UsersIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { kind: "Organisation" | "Team" | "Person"; tickets: number }

const person = (id: string, label: string, tickets: number): TreeNode<Member> => ({ id, label, icon: <UserIcon />, data: { kind: "Person", tickets } })
const team = (id: string, label: string, tickets: number, children: TreeNode<Member>[]): TreeNode<Member> => ({ id, label, icon: <UsersIcon />, data: { kind: "Team", tickets }, children })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    icon: <Building2Icon />,
    data: { kind: "Organisation", tickets: 14 },
    children: [team("nw-support", "Support", 9, [person("amara", "Amara Okafor", 5), person("jonas", "Jonas Berg", 4)]), team("nw-billing", "Billing", 5, [person("lena", "Lena Fischer", 5)])],
  },
  { id: "acme", label: "Acme Hosting", icon: <Building2Icon />, data: { kind: "Organisation", tickets: 7 }, children: [team("acme-eng", "Engineering", 7, [person("ravi", "Ravi Patel", 7)])] },
  { id: "globex", label: "Globex Shop", icon: <Building2Icon />, data: { kind: "Organisation", tickets: 2 }, children: [team("globex-ops", "Operations", 2, [person("sam", "Sam Rivera", 2)])] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "tickets", header: "Open tickets" },
  {
    id: "kind",
    header: "Type",
    cell: (node) => <Badge variant={node.data?.kind === "Organisation" ? "warning" : node.data?.kind === "Team" ? "info" : "success"}>{node.data?.kind}</Badge>,
  },
]

export default function TreeTableGridlines() {
  return (
    <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} gridlines defaultExpanded={["northwind"]} className="max-w-3xl" />
  )
}

Striped

striped tints every other row shown.

import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Log = { status: string; sent: string }

const email = (id: string, label: string, status: string, sent: string): TreeNode<Log> => ({ id, label, data: { status, sent } })

const CAMPAIGNS: TreeNode<Log>[] = [
  {
    id: "october",
    label: "October newsletter",
    data: { status: "Sent", sent: "1 Oct 2026" },
    children: [email("oct-eu", "Europe list", "Sent", "1 Oct 2026"), email("oct-us", "United States list", "Sent", "1 Oct 2026")],
  },
  {
    id: "launch",
    label: "Product launch",
    data: { status: "Partly sent", sent: "3 Oct 2026" },
    children: [email("launch-vip", "VIP customers", "Sent", "3 Oct 2026"), email("launch-all", "All customers", "Queued", "โ€”"), email("launch-trial", "Trial users", "Failed", "3 Oct 2026")],
  },
  { id: "receipts", label: "Order receipts", data: { status: "Sending", sent: "Daily" }, children: [email("receipts-shop", "Shop orders", "Sending", "Daily")] },
]

const COLUMNS: TreeTableColumn<Log>[] = [
  { id: "name", header: "Campaign" },
  { id: "status", header: "Status" },
  { id: "sent", header: "Sent" },
]

export default function TreeTableStriped() {
  return <TreeTable aria-label="Campaigns" nodes={CAMPAIGNS} columns={COLUMNS} striped defaultExpanded={["october", "launch"]} className="max-w-3xl" />
}

Checkbox selection

selectionMode="checkbox": checking a row checks its branch, a partly checked row shows a dash, and the heading checks every row.

import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableCheckboxSelection() {
  const [selected, setSelected] = useState(["amara"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Recipients of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="checkbox"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">{selected.length} rows checked.</p>
    </div>
  )
}

Sort

sortable columns sort by a click on their heading; siblings sort among themselves. Here the busiest come first with defaultSort.

import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4), person("lena", "Lena Fischer", "Accountant", 5)],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
  { id: "globex", label: "Globex Shop", data: { role: "Organisation", tickets: 2 }, children: [person("sam", "Sam Rivera", "Store manager", 2)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name", sortable: true },
  { id: "role", header: "Role", sortable: true },
  { id: "tickets", header: "Open tickets", sortable: true },
]

export default function TreeTableSort() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={ORGANISATIONS}
      columns={COLUMNS}
      defaultSort={{ id: "tickets", desc: true }}
      defaultExpanded={["northwind"]}
      className="max-w-3xl"
    />
  )
}

Pagination

pageSize={3} shows three sites a page, each with its own rows, and the range below.

import { useUiLocale } from "@booleanpress/ui/provider"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Site = { plan: string; emails: number }

const site = (id: string, label: string, plan: string, emails: number): TreeNode<Site> => ({
  id,
  label,
  data: { plan, emails },
  children: [
    { id: `${id}-forms`, label: "Contact forms", data: { plan: "", emails: Math.round(emails * 0.3) } },
    { id: `${id}-orders`, label: "Order emails", data: { plan: "", emails: Math.round(emails * 0.7) } },
  ],
})

const SITES: TreeNode<Site>[] = [
  site("shop", "shop.example.com", "Agency", 12400),
  site("blog", "blog.example.com", "Starter", 860),
  site("docs", "docs.example.com", "Starter", 120),
  site("events", "events.example.com", "Pro", 3200),
  site("store", "store.example.org", "Pro", 5100),
  site("news", "news.example.org", "Starter", 940),
  site("jobs", "jobs.example.org", "Starter", 310),
]

export default function TreeTablePagination() {
  const { locale } = useUiLocale()
  const number = new Intl.NumberFormat(locale)
  const columns: TreeTableColumn<Site>[] = [
    { id: "name", header: "Site" },
    { id: "plan", header: "Plan" },
    { id: "emails", header: "Emails this month", cell: (node) => number.format(node.data?.emails ?? 0) },
  ]

  return <TreeTable aria-label="Sites" nodes={SITES} columns={columns} pageSize={3} className="max-w-3xl" />
}

Scroll

scrollHeight="16rem" scrolls the rows under a header that stays in place; expandAllButton opens or closes every row.

import { useUiLocale } from "@booleanpress/ui/provider"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import { getExpandableIds, type TreeNode } from "@booleanpress/ui/tree"

type Folder = { messages: number; size: string }

const folder = (id: string, label: string, messages: number, size: string, children?: TreeNode<Folder>[]): TreeNode<Folder> => ({
  id,
  label,
  data: { messages, size },
  children,
})

const MAILBOX: TreeNode<Folder>[] = [
  folder("inbox", "Inbox", 1240, "84 MB", [
    folder("billing", "Billing", 310, "22 MB", [folder("invoices", "Invoices", 250, "18 MB"), folder("refunds", "Refunds", 60, "4 MB")]),
    folder("support", "Support", 930, "62 MB", [folder("open", "Open", 120, "9 MB"), folder("closed", "Closed", 810, "53 MB")]),
  ]),
  folder("sent", "Sent", 2100, "130 MB", [folder("receipts", "Receipts", 1800, "96 MB"), folder("newsletters", "Newsletters", 300, "34 MB")]),
  folder("archive", "Archive", 5400, "410 MB", [folder("archive-2025", "2025", 3100, "240 MB"), folder("archive-2026", "2026", 2300, "170 MB")]),
  folder("spam", "Spam", 85, "3 MB"),
]

export default function TreeTableScroll() {
  const { locale } = useUiLocale()
  const number = new Intl.NumberFormat(locale)
  const columns: TreeTableColumn<Folder>[] = [
    { id: "name", header: "Folder" },
    { id: "messages", header: "Messages", cell: (node) => number.format(node.data?.messages ?? 0) },
    { id: "size", header: "Size" },
  ]

  return (
    <TreeTable
      aria-label="Mailbox"
      nodes={MAILBOX}
      columns={columns}
      scrollHeight="16rem"
      expandAllButton
      defaultExpanded={getExpandableIds(MAILBOX)}
      className="max-w-3xl"
    />
  )
}

Loading

loading dims the rows under a spinner while they refresh, or draws placeholder rows before the first load.

import { useEffect, useState } from "react"
import { RefreshCwIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 } },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 } },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 } }] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableLoading() {
  const [loading, setLoading] = useState(false)

  // A pretend refresh that answers after 1.5 seconds; leaving the page cancels it.
  useEffect(() => {
    if (!loading) return
    const timer = setTimeout(() => setLoading(false), 1500)
    return () => clearTimeout(timer)
  }, [loading])

  return (
    <div className="flex w-full max-w-3xl flex-col items-end gap-4">
      <Button size="sm" onClick={() => setLoading(true)}>
        <RefreshCwIcon />
        Refresh
      </Button>
      <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} loading={loading} defaultExpanded={["northwind"]} />
      <TreeTable aria-label="Organisations, first load" nodes={[]} columns={COLUMNS} loading />
    </div>
  )
}

Empty

empty shows your own message and action in place of the rows.

import { Building2Icon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"

const COLUMNS: TreeTableColumn[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableEmpty() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={[]}
      columns={COLUMNS}
      className="max-w-3xl"
      empty={
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <Building2Icon />
            </EmptyMedia>
            <EmptyTitle>No organisations yet</EmptyTitle>
            <EmptyDescription>Add an organisation to group its teams and people.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button size="sm">
              <PlusIcon />
              New organisation
            </Button>
          </EmptyContent>
        </Empty>
      }
    />
  )
}

Lazy children

loadChildren fetches an organisation's teams the first time it opens, with a spinner in its toggle meanwhile.

import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  { id: "northwind", label: "Northwind Agency", data: { role: "Organisation", tickets: 14 } },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 } },
  { id: "globex", label: "Globex Shop", data: { role: "Organisation", tickets: 2 } },
]

// A fake request: an organisation's teams arrive after 1.2 seconds; a team's people load the same way.
function loadChildren(node: TreeNode<Member>): Promise<TreeNode<Member>[]> {
  const teams = node.data?.role === "Organisation"
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve(
          teams
            ? [
                { id: `${node.id}-support`, label: "Support", data: { role: "Team", tickets: 3 } },
                { id: `${node.id}-billing`, label: "Billing", data: { role: "Team", tickets: 1 } },
              ]
            : [{ id: `${node.id}-lead`, label: "Team lead", data: { role: "Person", tickets: 1 }, leaf: true }]
        ),
      1200
    )
  )
}

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableLazy() {
  return <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} loadChildren={loadChildren} className="max-w-3xl" />
}

Single selection

selectionMode="single": a click selects one row and clears the last.

import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableSingleSelection() {
  const [selected, setSelected] = useState(["amara"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Pick the recipient of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="single"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">Recipient: {selected[0] ?? "nobody"}</p>
    </div>
  )
}

Multiple selection

selectionMode="multiple": each click toggles a row, so several rows stay selected.

import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableMultipleSelection() {
  const [selected, setSelected] = useState(["amara", "ravi"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Recipients of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="multiple"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">{selected.length} {selected.length === 1 ? "row" : "rows"} selected.</p>
    </div>
  )
}

Accessibility

Semantics
A table with role="treegrid"; each body row has aria-level, aria-setsize, aria-posinset, aria-expanded when it has children, and aria-selected when rows can be chosen. A sortable heading has aria-sort and holds a button. A row checkbox carries the mixed state; a row loading its children is aria-busy, and a hidden live region says the provider's treeLoading with its label, or treeLoadFailed if the load fails (the row closes again; opening it tries again).
Labels
Name the table with aria-label or aria-labelledby. Each row's toggle is named by the provider's expandNode or collapseNode with the row's label, its checkbox by the label, and the heading checkbox by selectAll; the expand-all button by expandAll or collapseAll.
Focus
The rows are one tab stop: the focused row, else the first chosen one, else the first. The arrow keys move between rows; a focused row shows a 1 px --ring outline inside it. The headings' buttons are tab stops of their own.
Known limits
  • The focus moves by row, not by cell: interactive content in cells is reached with Tab only when it is in the tab order itself.
  • It is a tree with columns, not a full table: cell or row editing, column resizing, column reordering, column visibility, column groups, row reordering, a filter row and CSV export are left to Data table, which has each of them. Filter the nodes before passing them.

Keyboard

Keyboard
KeyBehaviour
โ†“Moves to the next row.
โ†‘Moves to the previous row.
โ†’Opens a closed row; on an open row, moves to its first child. Left Arrow in a right-to-left page.
โ†Closes an open row; otherwise moves to its parent. Right Arrow in a right-to-left page.
HomeMoves to the first row.
EndMoves to the last row shown.
EnterChooses the row, or checks it with checkboxes; with no selection, opens or closes it.
SpaceAs Enter.
*Opens every sibling of the focused row.
Aโ€“ZMoves to the next row whose label starts with the letters typed.
Shiftโ†“With multiple, moves to the next row and adds it to the choice or takes it away. Up Arrow likewise.
CtrlAWith multiple, chooses every row shown, or none when all are chosen.

API

TreeTable

TreeTable props
PropTypeDefaultDescription
columnsrequiredTreeTableColumn<TData>[]The columns, the first holding the tree.
nodesrequiredTreeNode<TData>[]The rows, as tree nodes: { id, label, icon?, children?, leaf?, disabled?, data? }.
defaultExpandedstring[]The rows open at first, when the table controls them.
defaultSelectedstring[]The rows chosen at first, when the table controls them.
defaultSortTreeTableSort | nullnullThe sort at first, when the table controls it.
emptyReactNodeWhat shows when there are no rows. Defaults to the provider's noResults string.
expandAllButtonbooleanfalseShows a button in the first heading that opens every row, or closes them all.
expandedstring[]The open rows' ids, when you control them.
gridlinesbooleanfalseDraws a line round every cell.
loadChildren((node: TreeNode<TData>) => Promise<TreeNode<TData>[]>)Loads the children of a row the first time it opens, with a spinner in its toggle meanwhile.
loadingbooleanfalseDims the rows under a spinner; with no rows yet, shows placeholder rows.
onExpandedChange((expanded: string[]) => void)Called with the open rows' ids.
onPageChange((page: number) => void)Called with the page asked for.
onSelectedChange((selected: string[]) => void)Called with the chosen rows' ids. With checkboxes it lists every checked row.
onSortChange((sort: TreeTableSort | null) => void)Called with the new sort when a heading is clicked: ascending, descending, then none.
pagenumberThe page, from 1, when you control it.
pageSizenumberShows this many top-level rows a page, with pagination below; their children stay with them.
scrollHeightstringA CSS height past which the rows scroll under a header that stays in place, such as "20rem".
selectedstring[]The chosen (or checked) rows' ids, when you control them.
selectionMode"none" | "checkbox" | "single" | "multiple"noneHow rows are chosen: none (default), single, multiple or checkbox.
size"default" | "sm" | "lg"Cell padding: sm 6 ร— 8 px, default 8 ร— 14 px, lg 15 ร— 20 px. Defaults to the provider's controlSize.
sortTreeTableSort | nullThe sort, when you control it.
stripedbooleanfalseTints every other row.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="tree-table-header" (TreeTable), and data-node-id, data-state, data-size.

Provider strings: collapseAll, expandAll, selectAll, noResults, collapseNode, expandNode, loading (BooleanUIProvider's strings).

Theming

Rows and cells are the Table's: --border lines (--muted in dark), --accent under the pointer, a chosen row --highlight. A sorted heading fills --highlight; a striped row takes --subtle (--background in dark). The toggle is --muted-foreground, --accent under the pointer.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentborder, background
--accent-foregroundtext
--backgroundbackground
--borderborder
--cardborder, background
--foregroundtext
--highlightbackground
--highlight-foregroundtext, outline
--mutedborder
--muted-foregroundtext
--primarytext
--ringoutline
--subtlebackground