ComponentsData
Tree
Shows nested items people open, close, choose, check, filter and move: folders, categories, an organisation.
Import
import { Tree } from "@booleanpress/ui/tree"Usage
Pass the nodes as an array; each node has an id, a label and, optionally, children. Name the tree with aria-label or aria-labelledby.
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const folders: TreeNode[] = [
{ id: "inbox", label: "Inbox", children: [{ id: "billing", label: "Billing" }, { id: "support", label: "Support" }] },
{ id: "sent", label: "Sent" },
]
export function Folders() {
return <Tree aria-label="Mail folders" nodes={folders} selectionMode="single" defaultExpanded={["inbox"]} />
}The open nodes and the chosen nodes are arrays of ids. Each is uncontrolled with defaultExpanded and defaultSelected, or controlled with expanded and onExpandedChange, selected and onSelectedChange. getExpandableIds(nodes) lists every parent, for an "expand all" button.
selectionMode is none (the default: a click opens and closes a parent), single, multiple (a click adds or removes a node) or checkbox. With checkboxes, checking a parent checks its whole branch, and a parent shows a dash while only part of its branch is checked; onSelectedChange receives every checked node, parents whose branch is all checked included. onNodeSelect is called with the node each click, Enter or Space chooses, even one already chosen.
A node can carry an icon (and an expandedIcon for its open state), disabled (it can be focused and opened, not chosen or moved), leaf and your own data. renderLabel draws the label and anything after it, such as an unread count; expandIcon and collapseIcon replace the chevrons.
filter adds a field above the tree that keeps the nodes whose label contains its text, opens their ancestors and keeps their own branch. loadChildren loads the children of a node that has none yet the first time it opens, with a spinner in place of its chevron; if its promise rejects, the node closes again, the live region says the provider's treeLoadFailed, and opening it tries again. loading dims the tree under a spinner, or draws placeholder rows while there are no nodes yet; empty is what shows when there are none.
onNodeMove makes the nodes movable with Alt and the arrow keys. It receives a move; moveTreeNode(nodes, move) returns the new nodes. The node it goes into opens, and the live region says the node's new position with the provider's itemMoved.
For pointer dragging as well, use DraggableTree from its own entry, @booleanpress/ui/tree-drag: it takes every prop of Tree, with onNodeMove required, and lets people drag a node before, after or onto another: a mouse after 4 px, a finger after a 250 ms press, so a swipe still scrolls the page. It is the only part that needs dnd kit: install @dnd-kit/core (6) to use it. Tree, TreeSelect and TreeTable need no drag library.
import { moveTreeNode } from "@booleanpress/ui/tree"
import { DraggableTree } from "@booleanpress/ui/tree-drag"
<DraggableTree aria-label="Theme files" nodes={nodes} onNodeMove={(move) => setNodes((current) => moveTreeNode(current, move))} />Examples
Basic
Mail folders, the first one open. A click on a parent opens or closes it.
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const FOLDERS: TreeNode[] = [
{
id: "inbox",
label: "Inbox",
children: [
{ id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
{ id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }, { id: "closed", label: "Closed tickets" }] },
],
},
{ id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }, { id: "newsletters", label: "Newsletters" }] },
{ id: "archive", label: "Archive", children: [{ id: "archive-2025", label: "2025" }, { id: "archive-2026", label: "2026" }] },
]
export default function TreeBasic() {
return <Tree aria-label="Mail folders" nodes={FOLDERS} defaultExpanded={["inbox"]} className="w-full md:w-120" />
}With icons and counts
icon and expandedIcon draw a folder that opens with its node; renderLabel adds an unread count after the label.
import { ArchiveIcon, FolderIcon, FolderOpenIcon, InboxIcon, MailIcon, SendIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
type Folder = { unread?: number }
const folder = (id: string, label: string, unread: number, children?: TreeNode<Folder>[]): TreeNode<Folder> => ({
id,
label,
icon: children ? <FolderIcon /> : <MailIcon />,
expandedIcon: children ? <FolderOpenIcon /> : undefined,
children,
data: { unread },
})
const FOLDERS: TreeNode<Folder>[] = [
{
id: "inbox",
label: "Inbox",
icon: <InboxIcon />,
data: { unread: 12 },
children: [folder("billing", "Billing", 4, [folder("invoices", "Invoices", 3), folder("refunds", "Refunds", 1)]), folder("support", "Support", 8)],
},
{ id: "sent", label: "Sent", icon: <SendIcon />, children: [folder("receipts", "Receipts", 0), folder("newsletters", "Newsletters", 0)] },
{ id: "archive", label: "Archive", icon: <ArchiveIcon />, children: [folder("archive-2026", "2026", 0)] },
]
export default function TreeIconsAndCounts() {
return (
<Tree
aria-label="Mail folders"
nodes={FOLDERS}
selectionMode="single"
defaultExpanded={["inbox"]}
defaultSelected={["support"]}
className="w-full md:w-120"
renderLabel={(node) => (
<>
<span className="flex-1">{node.label}</span>
{node.data?.unread ? <Badge count={node.data.unread} severity="secondary" /> : null}
</>
)}
/>
)
}Custom toggle indicator
expandIcon and collapseIcon replace the chevrons with a plus and a minus.
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const FOLDERS: TreeNode[] = [
{
id: "inbox",
label: "Inbox",
children: [
{ id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }] },
{ id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }] },
],
},
{ id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
{ id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]
export default function TreeToggleIndicator() {
return (
<Tree
aria-label="Mail folders"
nodes={FOLDERS}
defaultExpanded={["inbox"]}
expandIcon={<CirclePlusIcon />}
collapseIcon={<CircleMinusIcon />}
className="w-full md:w-120"
/>
)
}Controlled
expanded and onExpandedChange keep the open nodes in your state; the buttons open every parent with getExpandableIds, or close them all.
import { useState } from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Tree, getExpandableIds, type TreeNode } from "@booleanpress/ui/tree"
const FOLDERS: TreeNode[] = [
{
id: "inbox",
label: "Inbox",
children: [
{ id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
{ id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }] },
],
},
{ id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
{ id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]
export default function TreeControlled() {
const [expanded, setExpanded] = useState<string[]>([])
return (
<div className="flex w-full flex-col gap-2 md:w-120">
<div className="flex gap-2">
<Button onClick={() => setExpanded(getExpandableIds(FOLDERS))}>
<PlusIcon />
Expand all
</Button>
<Button variant="outline" onClick={() => setExpanded([])}>
<MinusIcon />
Collapse all
</Button>
</div>
<Tree aria-label="Mail folders" nodes={FOLDERS} expanded={expanded} onExpandedChange={setExpanded} />
</div>
)
}Single selection
selectionMode="single": one node at a time is chosen, filled with --highlight.
import { useState } from "react"
import { FileTextIcon, FolderIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const TEMPLATES: TreeNode[] = [
{
id: "transactional",
label: "Transactional",
icon: <FolderIcon />,
children: [
{ id: "welcome", label: "Welcome", icon: <FileTextIcon /> },
{ id: "password-reset", label: "Password reset", icon: <FileTextIcon /> },
],
},
{ id: "marketing", label: "Marketing", icon: <FolderIcon />, children: [{ id: "launch", label: "Product launch", icon: <FileTextIcon /> }] },
{ id: "receipts", label: "Receipts", icon: <FolderIcon />, children: [{ id: "order", label: "Order receipt", icon: <FileTextIcon /> }] },
]
export default function TreeSingleSelection() {
const [selected, setSelected] = useState(["password-reset"])
return (
<div className="flex w-full flex-col gap-2 md:w-120">
<Tree
aria-label="Email templates"
nodes={TEMPLATES}
selectionMode="single"
selected={selected}
onSelectedChange={setSelected}
defaultExpanded={["transactional"]}
/>
<p className="text-sm text-muted-foreground">Editing: {selected[0] ?? "nothing"}</p>
</div>
)
}Multiple selection
selectionMode="multiple": a click, Enter or Space adds a node or takes it away; Shift and an arrow key extends the choice.
import { FileTextIcon, FolderIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const TEMPLATES: TreeNode[] = [
{
id: "transactional",
label: "Transactional",
icon: <FolderIcon />,
children: [
{ id: "welcome", label: "Welcome", icon: <FileTextIcon /> },
{ id: "password-reset", label: "Password reset", icon: <FileTextIcon /> },
],
},
{ id: "marketing", label: "Marketing", icon: <FolderIcon />, children: [{ id: "launch", label: "Product launch", icon: <FileTextIcon /> }] },
{ id: "receipts", label: "Receipts", icon: <FolderIcon />, children: [{ id: "order", label: "Order receipt", icon: <FileTextIcon /> }] },
]
export default function TreeMultipleSelection() {
return (
<Tree
aria-label="Templates to export"
nodes={TEMPLATES}
selectionMode="multiple"
defaultExpanded={["transactional"]}
defaultSelected={["welcome", "password-reset"]}
className="w-full md:w-120"
/>
)
}Checkbox selection
selectionMode="checkbox": checking a parent checks its branch, and a partly checked branch shows a dash.
import { useState } from "react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const EVENTS: TreeNode[] = [
{
id: "delivery",
label: "Delivery",
children: [
{ id: "sent", label: "Sent" },
{ id: "delivered", label: "Delivered" },
{ id: "bounced", label: "Bounced" },
],
},
{ id: "engagement", label: "Engagement", children: [{ id: "opened", label: "Opened" }, { id: "clicked", label: "Clicked" }] },
{ id: "complaints", label: "Spam complaints" },
]
export default function TreeCheckboxSelection() {
const [checked, setChecked] = useState(["delivered"])
return (
<div className="flex w-full flex-col gap-2 md:w-120">
<Tree
aria-label="Webhook events"
nodes={EVENTS}
selectionMode="checkbox"
selected={checked}
onSelectedChange={setChecked}
defaultExpanded={["delivery"]}
/>
<p className="text-sm text-muted-foreground">Sending {checked.length} events to the webhook.</p>
</div>
)
}Filter
filter keeps the nodes that contain the text typed, with their ancestors opened; Down Arrow moves from the field into the tree.
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const HELP: TreeNode[] = [
{
id: "getting-started",
label: "Getting started",
children: [
{ id: "install", label: "Install the plugin" },
{ id: "connect", label: "Connect a mailer" },
],
},
{
id: "mailers",
label: "Mailers",
children: [
{ id: "smtp", label: "Other SMTP" },
{ id: "api", label: "API mailers", children: [{ id: "api-keys", label: "Create an API key" }, { id: "domains", label: "Verify a domain" }] },
],
},
{ id: "logs", label: "Delivery logs", children: [{ id: "retention", label: "Log retention" }, { id: "resend", label: "Resend an email" }] },
]
export default function TreeFilter() {
return <Tree aria-label="Help articles" nodes={HELP} filter filterPlaceholder="Search articles" className="w-full md:w-120" />
}Lazy loading
loadChildren fetches a folder's contents the first time it opens; a spinner shows in place of its chevron meanwhile.
import { FileTextIcon, FolderIcon, FolderOpenIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const folder = (id: string, label: string): TreeNode => ({ id, label, icon: <FolderIcon />, expandedIcon: <FolderOpenIcon /> })
const SITES: TreeNode[] = [folder("shop", "shop.example.com"), folder("blog", "blog.example.com"), folder("docs", "docs.example.com")]
// A fake request: the children of any folder arrive after 1.2 seconds.
function loadChildren(node: TreeNode): Promise<TreeNode[]> {
return new Promise((resolve) =>
setTimeout(
() =>
resolve([
folder(`${node.id}-forms`, "Contact forms"),
folder(`${node.id}-orders`, "Order emails"),
{ id: `${node.id}-log`, label: "error.log", icon: <FileTextIcon />, leaf: true },
]),
1200
)
)
}
export default function TreeLazy() {
return <Tree aria-label="Sites" nodes={SITES} loadChildren={loadChildren} className="w-full md:w-120" />
}Loading
loading dims the nodes 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 { Tree, type TreeNode } from "@booleanpress/ui/tree"
const FOLDERS: TreeNode[] = [
{ id: "inbox", label: "Inbox", children: [{ id: "billing", label: "Billing" }, { id: "support", label: "Support" }] },
{ id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
{ id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]
export default function TreeLoading() {
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 flex-col gap-4 md:flex-row">
<div className="flex flex-1 flex-col items-end gap-2">
<Button size="sm" onClick={() => setLoading(true)}>
<RefreshCwIcon />
Refresh
</Button>
<Tree aria-label="Mail folders" nodes={FOLDERS} defaultExpanded={["inbox"]} loading={loading} className="w-full" />
</div>
<Tree aria-label="Mail folders, first load" nodes={[]} loading className="flex-1" />
</div>
)
}Empty
empty shows your own message and action when there are no nodes.
import { FolderIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
import { Tree } from "@booleanpress/ui/tree"
export default function TreeEmpty() {
return (
<Tree
aria-label="Mail folders"
nodes={[]}
className="w-full md:w-120"
empty={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderIcon />
</EmptyMedia>
<EmptyTitle>No folders yet</EmptyTitle>
<EmptyDescription>Create a folder to start sorting incoming email.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm">
<PlusIcon />
New folder
</Button>
</EmptyContent>
</Empty>
}
/>
)
}Drag and drop
DraggableTree from @booleanpress/ui/tree-drag, with moveTreeNode: drag a file before, after or into a folder, or move the focused node with Alt and the arrow keys.
import { useState } from "react"
import { FileIcon, FolderIcon } from "lucide-react"
import { moveTreeNode, type TreeNode } from "@booleanpress/ui/tree"
import { DraggableTree } from "@booleanpress/ui/tree-drag"
const file = (id: string, label: string): TreeNode => ({ id, label, icon: <FileIcon />, leaf: true })
const INITIAL: TreeNode[] = [
{
id: "templates",
label: "templates",
icon: <FolderIcon />,
children: [file("welcome", "welcome.html"), file("receipt", "receipt.html")],
},
{ id: "partials", label: "partials", icon: <FolderIcon />, children: [file("header", "header.html"), file("footer", "footer.html")] },
file("styles", "styles.css"),
file("readme", "README.md"),
]
export default function TreeDragAndDrop() {
const [nodes, setNodes] = useState(INITIAL)
return (
<DraggableTree
aria-label="Theme files"
nodes={nodes}
defaultExpanded={["templates", "partials"]}
onNodeMove={(move) => setNodes((current) => moveTreeNode(current, move))}
className="w-full md:w-120"
/>
)
}Disabled nodes
A disabled node is dimmed: it can be focused and opened, but not checked, chosen or moved.
import { Tree, type TreeNode } from "@booleanpress/ui/tree"
const ROLES: TreeNode[] = [
{
id: "administrators",
label: "Administrators",
children: [
{ id: "owner", label: "Site owner", disabled: true },
{ id: "admins", label: "Admins" },
],
},
{ id: "editors", label: "Editors", children: [{ id: "authors", label: "Authors" }, { id: "contributors", label: "Contributors" }] },
{ id: "subscribers", label: "Subscribers", disabled: true },
]
export default function TreeDisabled() {
return (
<Tree
aria-label="Roles that get the report"
nodes={ROLES}
selectionMode="checkbox"
defaultSelected={["owner"]}
defaultExpanded={["administrators"]}
className="w-full md:w-120"
/>
)
}Move without dragging
A menu on every node calls moveTreeNode for Move up, Move down and Move into, so a pointer that cannot drag can reorder the tree; the keyboard moves the focused node with Alt and the arrows (onNodeMove).
import { useState } from "react"
import { FileIcon, FolderIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@booleanpress/ui/dropdown-menu"
import { moveTreeNode, Tree, type TreeNode } from "@booleanpress/ui/tree"
const file = (id: string, label: string): TreeNode => ({ id, label, icon: <FileIcon />, leaf: true })
const INITIAL: TreeNode[] = [
{ id: "templates", label: "templates", icon: <FolderIcon />, children: [file("welcome", "welcome.html"), file("receipt", "receipt.html")] },
{ id: "partials", label: "partials", icon: <FolderIcon />, children: [file("header", "header.html"), file("footer", "footer.html")] },
file("styles", "styles.css"),
file("readme", "README.md"),
]
// The siblings of a node and the folder that holds it (`null` at the top).
function place(nodes: TreeNode[], id: string, parent: TreeNode | null = null): { siblings: TreeNode[]; parent: TreeNode | null } | undefined {
if (nodes.some((node) => node.id === id)) return { siblings: nodes, parent }
for (const node of nodes) {
const found = node.children ? place(node.children, id, node) : undefined
if (found) return found
}
return undefined
}
export default function TreeMoveWithoutDragging() {
const [nodes, setNodes] = useState(INITIAL)
const move = (id: string, targetId: string, position: "before" | "after" | "inside") =>
setNodes((current) => moveTreeNode(current, { id, targetId, position }))
return (
<Tree
aria-label="Theme files"
nodes={nodes}
// The keyboard moves the focused node with Alt and the arrows; the menu is the same moves for a pointer.
onNodeMove={(next) => setNodes((current) => moveTreeNode(current, next))}
defaultExpanded={["templates", "partials"]}
className="w-full md:w-120"
renderLabel={(node) => {
const here = place(nodes, node.id)
if (!here) return node.label
const index = here.siblings.findIndex((sibling) => sibling.id === node.id)
const previous = here.siblings[index - 1]
const next = here.siblings[index + 1]
const folders = nodes.filter((folder) => folder.children && folder.id !== node.id && folder.id !== here.parent?.id)
return (
<>
<span className="min-w-0 flex-1 truncate">{node.label}</span>
{/* The menu belongs to the row but not to its selection: its keys and clicks stay inside it. */}
<span role="presentation" className="ms-auto" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Out of the tab order: the tree is one tab stop, and the keyboard has Alt and the arrows. */}
<Button variant="ghost" size="icon-xs" tabIndex={-1} aria-label={`Actions for ${node.label}`}>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled={!previous} onSelect={() => previous && move(node.id, previous.id, "before")}>
Move up
</DropdownMenuItem>
<DropdownMenuItem disabled={!next} onSelect={() => next && move(node.id, next.id, "after")}>
Move down
</DropdownMenuItem>
{folders.length > 0 ? <DropdownMenuSeparator /> : null}
{folders.map((folder) => (
<DropdownMenuItem key={folder.id} onSelect={() => move(node.id, folder.id, "inside")}>
Move into {folder.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</span>
</>
)
}}
/>
)
}Accessibility
- Semantics
- A
ulwithrole="tree"; each node is anliwithrole="treeitem",aria-level,aria-setsizeandaria-posinset, andaria-expandedwhen it has children; its children are aulwithrole="group". Chosen nodes carryaria-selected, checked onesaria-checked(true,falseormixed); with several, the tree hasaria-multiselectable. A node loading its children isaria-busy. A hidden live region says the provider'streeLoadingwith its label while it loads,treeLoadFailedif the load fails, anditemMovedwith the new position after a move. - Labels
- Name the tree with
aria-labeloraria-labelledby. A node's name is its label (with whateverrenderLabeladds). The filter field is named by the provider'sfilterTreestring. - Focus
- The tree is one tab stop: the focused node, else the first chosen one, else the first. The arrow keys move the focus; a focused node shows a 1 px
--ringoutline inside its row. When a branch closes over the focus, the focus moves to its parent. - Known limits
- The chevrons and checkboxes are drawn for the pointer and hidden from assistive technology: the node's own
aria-expandedandaria-checkedcarry the state. - Dragging (
DraggableTree) needs a mouse, or a finger held for 250 ms; the keyboard moves nodes with Alt and the arrows. A pointer that cannot drag has no move of its own: offer a menu that callsmoveTreeNode(WCAG 2.5.7). moveTreeNodemoves the nodes you pass. Children thatloadChildrenbrought in are kept by the tree until you add them tonodes, so moving one of them needs your own update.- Every node shown is drawn: no rows are virtual. A branch of thousands of nodes is best loaded with
loadChildrenor narrowed with the filter. - With a filter, type-ahead and the arrows move among the nodes shown.
- The chevrons and checkboxes are drawn for the pointer and hidden from assistive technology: the node's own
Keyboard
| Key | Behaviour |
|---|---|
| โ | Moves to the next node shown. |
| โ | Moves to the previous node shown. |
| โ | Opens a closed node; on an open node, moves to its first child. Left Arrow in a right-to-left page. |
| โ | Closes an open node; otherwise moves to its parent. Right Arrow in a right-to-left page. |
| Home | Moves to the first node. |
| End | Moves to the last node shown. |
| Enter | Chooses the node, or checks it with checkboxes; with no selection, opens or closes it. |
| Space | As Enter. |
| * | Opens every sibling of the focused node. |
| AโZ | Moves to the next node whose label starts with the letters typed. |
| Shiftโ | With multiple, moves to the next node and adds it to the choice or takes it away. Up Arrow likewise. |
| CtrlA | With multiple, chooses every node shown, or none when all are chosen. |
| Altโ | With onNodeMove, moves the node before its previous sibling. Down Arrow moves it after the next. The live region says its new position. |
| Altโ | With onNodeMove, moves the node into its previous sibling, as its last child. Alt and Left Arrow moves it out, after its parent. Flipped in a right-to-left page. |
API
Tree
| Prop | Type | Default | Description |
|---|---|---|---|
nodesrequired | TreeNode<TData>[] | The nodes to show. | |
collapseIcon | ReactNode | The mark on an open node, in place of the chevron. | |
defaultExpanded | string[] | The nodes open at first, when the tree controls them. | |
defaultSelected | string[] | The nodes chosen at first, when the tree controls them. | |
empty | ReactNode | What shows when there are no nodes. Defaults to the provider's noResults string. | |
expanded | string[] | The open nodes' ids, when you control them. | |
expandIcon | ReactNode | The mark on a closed node, in place of the chevron. | |
filter | boolean | false | Shows a field above the tree that keeps the nodes whose label contains its text, with their ancestors. |
filterPlaceholder | string | The filter's placeholder. Defaults to the provider's filterTree string. | |
filterValue | string | The filter's text, when you control it. | |
loadChildren | ((node: TreeNode<TData>) => Promise<TreeNode<TData>[]>) | Loads the children of a node that has none yet and is not a leaf, the first time it opens; a spinner shows meanwhile. | |
loading | boolean | false | Dims the nodes under a spinner; with no nodes yet, shows placeholder rows. |
onExpandedChange | ((expanded: string[]) => void) | Called with the open nodes' ids when a node opens or closes. | |
onFilterValueChange | ((value: string) => void) | Called with the filter's text as it is typed. | |
onNodeMove | ((move: TreeMove) => void) | Makes nodes movable by Alt and the arrow keys; called with each move. Apply it with moveTreeNode. For pointer
dragging as well, use DraggableTree from @booleanpress/ui/tree-drag. | |
onNodeSelect | ((node: TreeNode<TData>) => void) | Called with the node a click, Enter or Space chooses or checks, even when it was chosen already. | |
onSelectedChange | ((selected: string[]) => void) | Called with the chosen nodes' ids. With checkboxes it lists every checked node, parents whose branch is all checked included. | |
renderLabel | ((node: TreeNode<TData>, state: TreeNodeState) => ReactNode) | Draws a node's label and anything after it, such as a count. Defaults to the label. | |
selected | string[] | The chosen (or, with checkboxes, checked) nodes' ids, when you control them. | |
selectionMode | "none" | "checkbox" | "single" | "multiple" | none | How nodes are chosen: none (default), single, multiple or checkbox. |
Also exported: getExpandableIds, a helper the parts use; moveTreeNode, a helper the parts use; useTreeState, a hook for the parts' shared state; call it inside the component's provider.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="tree-group" (Tree), and data-node-id.
Provider strings: treeLoadFailed, itemMoved, treeLoading, noResults, filterTree, loading (BooleanUIProvider's strings).
Theming
Rows are --foreground on the --card surface; a node that can be chosen takes --accent under the pointer, and a chosen one --highlight with --highlight-foreground; focus is a 1 px --ring outline inside the row. In a DraggableTree, a drop line is --primary, and the node being dragged has a dashed --primary outline.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--accent-foreground | text |
--card | background |
--control-hover | text |
--foreground | text |
--highlight | background |
--highlight-foreground | text |
--muted-foreground | text |
--primary | outline, background, text |
--ring | outline |