Tree
ProA hierarchical list with expand and collapse, roving focus, type-ahead, selection, icons, metadata and context menus, for explorers, entity browsers and outlines.
npx prodular-ui add treePart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Explorer
Icons, status metadata, a disabled node and a context menu on every row. Arrow keys move, Right and Left open and close, type to jump.
import * as React from "react"import { DatabaseIcon, FileTextIcon, FolderIcon, ServerIcon } from "lucide-react"
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut } from "@/components/ui/context-menu"import { Status } from "@/components/ui/status"import { Tree, type TreeNode } from "@/components/ui/tree"
const items: TreeNode[] = [ { id: "prod", label: "Production", icon: <FolderIcon />, meta: "4", children: [ { id: "prod/api", label: "api-gateway", icon: <ServerIcon />, meta: <Status variant="success" size="xs">Healthy</Status> }, { id: "prod/billing", label: "billing-worker", icon: <ServerIcon />, meta: <Status variant="warning" size="xs">Degraded</Status> }, { id: "prod/db", label: "Databases", icon: <FolderIcon />, children: [ { id: "prod/db/ledger", label: "ledger-primary", icon: <DatabaseIcon /> }, { id: "prod/db/replica", label: "ledger-replica", icon: <DatabaseIcon /> }, ], }, { id: "prod/legacy", label: "legacy-cron", icon: <ServerIcon />, disabled: true }, ], }, { id: "runbooks", label: "Runbooks", icon: <FolderIcon />, children: [ { id: "runbooks/failover", label: "Database failover", icon: <FileTextIcon /> }, { id: "runbooks/rollback", label: "Roll back a deploy", icon: <FileTextIcon /> }, ], },]
export default function TreeExplorer() { const [selected, setSelected] = React.useState<string[]>(["prod/billing"]) return ( <div className="w-72 rounded-lg border bg-card p-1"> <Tree aria-label="Services" items={items} defaultExpanded={["prod", "prod/db"]} selected={selected} onSelectedChange={setSelected} contextMenu={(node) => ( <ContextMenuContent> <ContextMenuItem> Open {typeof node.label === "string" ? node.label : "item"} <ContextMenuShortcut>↵</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem>Copy link</ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem variant="destructive">Archive</ContextMenuItem> </ContextMenuContent> )} /> </div> )}Ten thousand nodes
useTree flattens the visible rows, so a virtualizer renders only what is on screen. Keyboard focus follows into rows that render later.
import * as React from "react"import { useVirtualizer } from "@tanstack/react-virtual"import { FileIcon, FolderIcon } from "lucide-react"
import { TreeItem, useTree, type TreeNode } from "@/components/ui/tree"
// 50 folders with 200 files each: 10,050 nodes. Only the visible rows render.const items: TreeNode[] = Array.from({ length: 50 }, (_, f) => ({ id: `folder-${f}`, label: `Batch ${String(f + 1).padStart(2, "0")}`, icon: <FolderIcon />, meta: "200", children: Array.from({ length: 200 }, (_, i) => ({ id: `folder-${f}/file-${i}`, label: `invoice-${f * 200 + i + 1000}.pdf`, icon: <FileIcon /> })),}))
export default function TreeVirtual() { const tree = useTree({ items, defaultExpanded: ["folder-0", "folder-1"] }) const scrollRef = React.useRef<HTMLDivElement>(null) // The row height is the density's --row-height: 32px at the default density. const virtualizer = useVirtualizer({ count: tree.rows.length, getScrollElement: () => scrollRef.current, estimateSize: () => 32, overscan: 10 })
// Keep the focused row rendered while the keyboard moves through the list. const activeIndex = tree.rows.findIndex((r) => r.node.id === tree.activeId) React.useEffect(() => { if (activeIndex >= 0 && scrollRef.current?.contains(document.activeElement)) virtualizer.scrollToIndex(activeIndex, { align: "auto" }) }, [activeIndex, virtualizer])
return ( <div ref={scrollRef} className="h-80 w-80 overflow-auto rounded-lg border bg-card p-1"> <div role="tree" aria-label="Invoices" className="relative text-sm" style={{ height: virtualizer.getTotalSize() }}> {virtualizer.getVirtualItems().map((v) => { const row = tree.rows[v.index] return ( <TreeItem key={row.node.id} row={row} toggle={tree.toggle} {...tree.getItemProps(row)} className="absolute inset-x-0" style={{ top: v.start }} > {row.node.label} </TreeItem> ) })} </div> </div> )}Anatomy
<Tree aria-label="Services" items={items} defaultExpanded={["prod"]} onAction={(node) => open(node.id)} contextMenu={(node) => <ContextMenuContent>…</ContextMenuContent>}/>Guidance
Use it when
- Hierarchies people browse and act on: services, folders, accounts, investigations.
- contextMenu for actions on a node, so right click and the keyboard menu key both work.
- useTree with a virtualizer above a few thousand visible rows.
Avoid
- A Tree for one level of items. Use a list or Item group.
- Navigation between pages. Use Sidebar or SettingsNav.
- Putting buttons inside rows. Use contextMenu or the inspector for row actions.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Give Tree an aria-label or aria-labelledby.
- Every node id must be unique in the whole tree. When one record appears in two branches, give each occurrence its own id, for example "case-12/entity-4", and map selection back to the record yourself.
- Put plain text in textValue when label is not a string, so type-ahead and the accessible name work.
- Do not build a tree from Collapsible and divs with role="treeitem". Use Tree or useTree, which handle roving focus, aria-level, aria-setsize and aria-posinset.
- Use selectionMode="none" when rows only open things, and onAction for Enter and double click.
- When rendering rows yourself, spread getItemProps(row) on each TreeItem and keep role="tree" on the container.
API
Tree
| Prop | Type | Default | Description |
|---|---|---|---|
| items | TreeNode[] | Nodes: id, label, textValue, icon, meta, children, disabled. | |
| expanded | string[] | Open node ids. Use defaultExpanded for uncontrolled. | |
| selectionMode | "none" | "single" | "multiple" | "single" | multiple sets aria-multiselectable; Cmd or Ctrl click and Space add to the selection. |
| selected | string[] | Selected node ids. Use defaultSelected for uncontrolled. | |
| onAction | (node: TreeNode) => void | Enter or double click. | |
| contextMenu | (node: TreeNode) => ReactNode | Returns a ContextMenuContent for the node. |
useTree
| Prop | Type | Default | Description |
|---|---|---|---|
| returns | { rows, getItemProps, toggle, select, focus, activeId } | Flat visible rows and props for each row, for custom or virtualized rendering. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add tree# or, with the registry in components.jsonnpx shadcn@latest add @prodular/tree