Skip to the content

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 ul with role="tree"; each node is an li with role="treeitem", aria-level, aria-setsize and aria-posinset, and aria-expanded when it has children; its children are a ul with role="group". Chosen nodes carry aria-selected, checked ones aria-checked (true, false or mixed); with several, the tree has aria-multiselectable. A node loading its children is aria-busy. A hidden live region says the provider's treeLoading with its label while it loads, treeLoadFailed if the load fails, and itemMoved with the new position after a move.
Labels
Name the tree with aria-label or aria-labelledby. A node's name is its label (with whatever renderLabel adds). The filter field is named by the provider's filterTree string.
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 --ring outline 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-expanded and aria-checked carry 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 calls moveTreeNode (WCAG 2.5.7).
  • moveTreeNode moves the nodes you pass. Children that loadChildren brought in are kept by the tree until you add them to nodes, 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 loadChildren or narrowed with the filter.
  • With a filter, type-ahead and the arrows move among the nodes shown.

Keyboard

Keyboard
KeyBehaviour
โ†“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.
HomeMoves to the first node.
EndMoves to the last node shown.
EnterChooses the node, or checks it with checkboxes; with no selection, opens or closes it.
SpaceAs Enter.
*Opens every sibling of the focused node.
Aโ€“ZMoves 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.
CtrlAWith 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

Tree props
PropTypeDefaultDescription
nodesrequiredTreeNode<TData>[]The nodes to show.
collapseIconReactNodeThe mark on an open node, in place of the chevron.
defaultExpandedstring[]The nodes open at first, when the tree controls them.
defaultSelectedstring[]The nodes chosen at first, when the tree controls them.
emptyReactNodeWhat shows when there are no nodes. Defaults to the provider's noResults string.
expandedstring[]The open nodes' ids, when you control them.
expandIconReactNodeThe mark on a closed node, in place of the chevron.
filterbooleanfalseShows a field above the tree that keeps the nodes whose label contains its text, with their ancestors.
filterPlaceholderstringThe filter's placeholder. Defaults to the provider's filterTree string.
filterValuestringThe 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.
loadingbooleanfalseDims 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.
selectedstring[]The chosen (or, with checkboxes, checked) nodes' ids, when you control them.
selectionMode"none" | "checkbox" | "single" | "multiple"noneHow 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.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--cardbackground
--control-hovertext
--foregroundtext
--highlightbackground
--highlight-foregroundtext
--muted-foregroundtext
--primaryoutline, background, text
--ringoutline