Skip to content

Tree

An arbitrary-depth hierarchical tree — recursive expand/collapse nodes with the full WAI-ARIA Tree View keyboard model, for file explorers and nav sidebars.

Stablev0.4.2added in v0.2.0@garn/ui/tree
On this page

Default

A file tree — terse nodes with folder/file icons, a disabled node, default-expanded branches.

Selection

Single-select navigation — one active node (aria-selected), selection following focus.

Checkable

A checkbox tree — tri-state parent↔child cascade with indeterminate parents.

Sizes

The size ladder — sm · md · lg row geometry.

Guides Actions

Indent guide lines, trailing badges, and per-node hover actions.

Filter

Filter-to-reveal — Tree.Search hides non-matches, auto-expands ancestors, highlights.

Async

Async lazy children — a loadable node shows a spinner then loads its subtree on expand.

Multi Select

Multi-select — ⌘/Ctrl+A, Shift+Arrow range, PageUp/Down.

Rename

Inline rename — F2 / double-click swaps the label for an editable field.

Context Menu

Per-node right-click actions — a ContextMenu composed inside the item.

Skeleton

A loading preset — indented chevron/icon/label placeholders.

Empty

An empty state via Tree.Empty composing <Empty>.

Reorderable

Drag-reparent — a grip handle per node; drag to move nodes between parents (3-way drop zones, cycle-safe), with onMove reshaping nested data.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add tree
First time? Set up garn in your project
Registry
tree
Deps
@radix-ui/react-slotlucide-react
Registry deps
utilsuse-announceuse-controllable-key-setsortablecheckboxinputspinner

Import

import { Tree.DragHandle, Tree.Item, Tree.Group, Tree.Icon, Tree.Label, Tree.Trailing, Tree.Actions, Tree.Search, Tree.Empty, Tree.Skeleton } from "@garn/ui/tree";

Anatomy

The parts this component is built from — compose them to assemble it.

Tree.DragHandle[data-slot="tree-drag-handle"]

The grip that reparents a node when the Tree is reorderable — a real <button> with an accessible name. Only the handle starts a drag, so the node keeps its role=treeitem semantics (dragging the whole row would break the tree a11y model). Renders nothing when the tree isn't reorderable. Pointer drag comes from the sortable engine; the keyboard model (pick up · arrows move · right nests · left outdents · Enter drops · Escape cancels) is the tree's own.

Tree.ItemRequired[data-slot="tree-item"]

One node — a <li role=treeitem> carrying aria-level/setsize/posinset, aria-expanded (parents), aria-selected (selection modes), and the roving tabindex. Dual-mode: terse (label/icon/trailing/actions) or composed children. Nested <Tree.Item>s become its role=group. Props: value (key), disabled, loadable/loading (async), editable/onRename (rename), onActivate. Reflects data-level/expanded/selected/checked/disabled/editable/state.

Tree.Group[data-slot="tree-group"]

The <ul role=group> wrapping a node's child items — auto-rendered by Tree.Item when it has nested items (also exported for explicit composition). Carries the per-level indent and, with guides, the vertical guide line.

Tree.Icon[data-slot="tree-icon"]

The leading icon; reflects the node's open state via data-expanded (open/closed-aware folder icons).

Tree.Label[data-slot="tree-label"]

The node's primary text. asChild swaps in an <a>/<button> for a navigational tree; truncates; hosts the match highlight.

Tree.Trailing[data-slot="tree-trailing"]

Inert trailing metadata (a count / badge) — pushed to the row's end, not a tab stop.

Tree.Actions[data-slot="tree-actions"]

Trailing focusable controls (buttons / overflow menu) with their own tab stops; revealed on row hover/focus.

Tree.Search[data-slot="tree-search"]

A search <input> that drives the tree's filter-to-reveal. Placed above the tree content (hoisted out of the <ul>); matches auto-expand their ancestors and highlight in place.

Tree.Empty[data-slot="tree-empty"]

An empty-state slot — drop a garn <Empty> inside; a full-width tree row.

Tree.Skeleton[data-slot="tree-skeleton"]

A loading preset — indented rows of chevron + icon + label placeholders in the node's geometry, hidden from AT.

Props

The component's public props and their types.

checkedStrategy
Description
What `onCheckedChange` emits: everything checked, only topmost parents, or only leaves. Internal state is always leaves; this shapes the callback.
Type
"all" | "leaves" | "parents"
Default
"all"
selectionMode
Description
Selection model — sets `aria-selected` on the active node(s).
Type
"multiple" | "none" | "single"
Default
"none"
size
Description
Row height / type ladder.
Type
"lg" | "md" | "sm"
Default
"md"
cascade
Description
Parent↔child check conduction. `false` = independent checks (checkStrictly).
Type
boolean
Default
true
checkable
Description
Render a checkbox per node (independent of `selectionMode`).
Type
boolean
Default
false
checkedKeys
Description
Controlled checked keys (parents included when fully checked).
Type
string[]
defaultCheckedKeys
Description
Uncontrolled initial checks.
Type
string[]
defaultExpandedKeys
Description
Uncontrolled initial expansion.
Type
string[]
defaultFilterValue
Description
Uncontrolled initial filter query (drive it with `<Tree.Search>`).
Type
string
defaultSelectedKeys
Description
Uncontrolled initial selection.
Type
string[]
density
Description
Local density override; omit to inherit the ambient `[data-density]`.
Type
Density
disabledKeys
Description
Keys that cannot be selected/checked/focused.
Type
string[]
expandedKeys
Description
Controlled expanded keys.
Type
string[]
filterValue
Description
Controlled filter query — hides non-matches, auto-reveals matching branches.
Type
string
guides
Description
Draw vertical indent guide lines down each level.
Type
boolean
Default
false
label
Description
Convenience accessible name for the tree.
Type
React.ReactNode
onCheckedChange
Description
Called with the next checked set (leaves + fully-checked parents).
Type
(keys: string[]) => void
onExpandedChange
Description
Called with the next expanded set whenever it changes.
Type
(keys: string[]) => void
onFilterChange
Description
Called with the next filter query whenever it changes.
Type
(query: string) => void
onMove
Description
Fires when a node is dropped onto a new parent/position. You move your data: remove `value` from its old spot and insert it into `parent`'s children at `index` (a null `parent` means the tree root). Cycles are pre-filtered out.
Type
(value: string, target: { parent: string | null; index: number; }) => void
onSelectionChange
Description
Called with the next selection whenever it changes.
Type
(keys: string[]) => void
reorderable
Description
Enable drag-reparent (needs a `<Tree.DragHandle>` in each node's row).
Type
boolean
Default
false
revealKey
Description
Reveal a node: expand its ancestors, then focus + scroll it into view.
Type
string
selectedKeys
Description
Controlled selected keys (each `Tree.Item` needs a `value`).
Type
string[]
selectionFollowsFocus
Description
In `single` mode, move selection as focus moves.
Type
boolean
Default
false

Plus 279 inherited native <ul> attributes.

Tree.DragHandle

label
Description
Accessible name for the icon-only handle.
Type
string
Default
"Drag to move"

Plus 290 inherited native HTML attributes.

Tree.Item

actions
Description
Terse mode: trailing focusable controls (kept above the row).
Type
React.ReactNode
disabled
Description
Inert, non-selectable, non-checkable, skipped by nav + cascade.
Type
boolean
Default
false
editable
Description
Allow inline rename (F2 / double-click the label). Requires a string `label`.
Type
boolean
Default
false
icon
Description
Terse mode: the leading icon (open/closed-aware via context).
Type
React.ReactNode
label
Description
Terse mode: the node label (also the primary click / activation target).
Type
React.ReactNode
loadable
Description
Marks a parent whose children load on demand — shows a chevron with no items yet.
Type
boolean
Default
false
loading
Description
Children are loading — swaps the chevron for a spinner.
Type
boolean
Default
false
onActivate
Description
Fired when the row is activated (Enter / click).
Type
(event: React.SyntheticEvent) => void
onRename
Description
Called with the committed label when an inline rename is confirmed.
Type
(nextLabel: string) => void
trailing
Description
Terse mode: inert trailing metadata (count / badge).
Type
React.ReactNode
valueRequired
Description
Stable key — required to participate in expansion / selection / checks.
Type
string

Plus 277 inherited native HTML attributes.

Tree.Label

asChild
Description
Render as the child element (e.g. a link or heading), merging props.
Type
boolean
Default
false

Plus 280 inherited native HTML attributes.

Tree.Skeleton

icon
Description
Show a leading icon placeholder.
Type
boolean
Default
true
rows
Description
How many placeholder rows to render.
Type
number
Default
5
size
Description
Row height / type ladder for the placeholder rows.
Type
TreeSize
Default
"md"

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="tree"][data-slot="tree-label-heading"][data-slot="tree-row"][data-slot="tree-toggle"][data-slot="tree-check"][data-slot="tree-match"][data-slot="tree-rename"][data-slot="tree-drop-indicator"]

States

SelectorState
[data-expanded]expanded
[data-selected]selected
[data-checked]checked
[data-disabled]disabled
[data-editable]editable
[data-dragging]dragging
[data-drop-zone]drop-zone
PropertyTokenTier
rowGap--garn-gap-inlinesemantic · air
rowHeight--garn-control-h-mdcomponent · identity
rowRadius--garn-radiussemantic
motion--garn-motion-fastsemantic · air

Private instance vars (never externalize): --tree-indent

When to use

A hierarchy the user expands and collapses to arbitrary depth — a file/folder explorer, a nested navigation sidebar, a category/org structure, threaded comments. Use terse <Tree.Item> for the common node and compose the slots for rich rows; set selectionMode for active-node selection, checkable for a checkbox tree, and Tree.Search / filterValue to find nodes in a deep tree.

Reach for something else when

  • Single-level expandable detail rows inside a data grid (use Table's expandable rows)
  • a flat collection of peer rows with no hierarchy (use List)
  • a handful of stacked open/close content sections (use Accordion or Collapsible)
  • a single entity's field:value facts (use DataList)
  • picking from a popover (use Select / Combobox).

Overview

Tree is the hierarchical collection — recursive, same-shape nodes that expand and collapse to arbitrary depth, navigated with a full WAI-ARIA Tree View keyboard model. Reach for it for file/folder explorers, nested navigation sidebars, and threaded or org/category structures. Tree.Item is one node (terse label/icon/trailing/actions, or composed slots); nested items become its children. It scales from a read-only outline to selection (single active node, or multi-select with range + ⌘A), to a checkbox tree with tri-state cascade — plus filter-to-reveal, scroll-to-node, async lazy loading, and inline rename. For single-level expandable rows in a grid use Table; for peer rows with no hierarchy use List; for a few stacked open/close sections use Accordion.

Guidelines

Give every node a stable value — it keys expansion, selection, and checks. Drive state with the controlled/uncontrolled pairs (expandedKeys/defaultExpandedKeys, selectedKeys, checkedKeys) and their on…Change callbacks.

Choose the interaction model deliberately. selectionMode is the active-node model (single = one highlighted node, multiple = range/⌘A); checkable is the orthogonal checkbox model (containment cascade by default, cascade={false} for independent flags). They can coexist as two independent state sets.

Lean on the built-in depth. Tree.Search (or filterValue) filters and auto-reveals; revealKey scrolls a deep node into view; loadable/loading load a subtree on first expand; editable/onRename rename in place; reorderable + onMove + a Tree.DragHandle in each row add drag-reparent (pointer + keyboard, 3-way drop zones, cycle-safe). Virtualization and a data-driven items API stay recipes with a windowing library, never built-in props.

Keep it accessible. Name the tree; don't strip the roving model or the focus ring; convey state with the checkbox/aria, not colour alone.

Best practices

Do
  • Give the tree an accessible name (label / aria-label) and every node a stable value.
  • Drive expansion/selection/checks with the controlled or default key sets and their on…Change callbacks.
  • Use selectionMode for the active-node model and the orthogonal checkable for a containment checkbox tree (they can coexist).
  • Add Tree.Search (or filterValue) for find-in-tree, and loadable/loading to lazy-load a subtree on first expand.
  • Enable reorderable + onMove with a Tree.DragHandle in each row to let users drag nodes to a new parent/position (apply the move to your data in onMove).
  • Show Tree.Skeleton while the tree loads and an <Empty> (via Tree.Empty) when it's cleared.
Don't
  • Don't wrap a whole <Tree.Item> in another component (e.g. a ContextMenu trigger) — it hides the node from the tree model; compose inside the item instead.
  • Don't put asChild directly on Tree.Label under a Radix trigger — it clobbers the label's data-slot (breaking type-ahead/highlight); wrap a plain element within the label.
  • Don't reach for Tree for a flat list, a data grid, or a couple of collapsible sections — use List / Table / Accordion.
  • Don't rely on colour alone for checkbox state — the tri-state Checkbox carries aria-checked (incl. mixed).

Content guidelines

  • Lead the node with its name; keep trailing metadata short (a count / badge) and put actions in Tree.Actions.
  • Use open/closed-aware icons (folder vs folder-open) via Tree.Icon's data-expanded for scannability.

Troubleshooting

Nodes don't expand/select, or aria-level is missing on some nodes.

Cause. Nested items were wrapped in another component (e.g. a ContextMenu trigger), hiding them from the child-walked tree model.

Fix. Keep nested <Tree.Item>s as direct children (Fragments/Tree.Group are fine). To add a per-node context menu, compose the ContextMenu INSIDE the item (trigger on an element within Tree.Label), never around the item.

Filter highlight / type-ahead stops working for a node after adding a context menu or link.

Cause. asChild was put directly on Tree.Label under a Radix trigger, which clobbers the label's data-slot (spread order).

Fix. Keep the real Tree.Label slot and wrap a plain element (e.g. a <span>) inside it as the trigger / link target.

Expecting checkedKeys and selectedKeys to be the same thing.

Cause. checkable (checkbox containment) and selectionMode (active node) are two independent state models with separate key sets.

Fix. Drive them separately — checkedKeys/onCheckedChange for the checkbox tree, selectedKeys/onSelectionChange for the active node; they can be on at once.

onCheckedChange returns parent keys you didn't expect (or omits them).

Cause. The default checkedStrategy="all" emits leaves + fully-checked parents.

Fix. Set checkedStrategy to "leaves" (only leaves) or "parents" (only topmost fully-checked) to shape the payload; internal state stays leaves so it round-trips.

Async subtree never loads, or a folder has no chevron until data arrives.

Cause. Tree doesn't fetch — a node with no children and no loadable renders as a leaf.

Fix. Mark the node loadable (shows a chevron), set loading while fetching (shows a Spinner), and load its children in onExpandedChange; render them when they arrive.

Using Tree for single-level expandable rows in a table, or for a flat list.

Cause. Tree is an arbitrary-depth role=tree, not a grid or a flat collection.

Fix. Use Table's expandable rows for one level of detail in a grid, and List for a flat collection of peer rows.

reorderable is on but nodes won't drag (or the label/icon disappears when adding a Tree.DragHandle).

Cause. The drag lives on Tree.DragHandle (not the row), and adding any composed child switches Tree.Item into composed mode — so the terse label/icon props are ignored.

Fix. Put a <Tree.DragHandle> in the node's row, and in that composed node render the content as slots (<Tree.Icon>/<Tree.Label>) rather than the terse label/icon props. Apply the reparent to your data in onMove(value, { parent, index }).

Accessibility

Role
tree
Focus
A single roving tabindex on the treeitems (active node = 0, others = -1); Arrow keys move focus and the focus ring is drawn on the row from the focused <li>. The chevron and per-node checkbox are tabindex=-1 (operated by keyboard-at-the-treeitem or pointer) so the tree stays one tab stop; Tree.Actions rove with the active node.

Accessibility requirements

warn

Give the Tree an accessible name via `label`, `aria-label`, or `aria-labelledby`.

when a Tree has no `label`, `aria-label`, or `aria-labelledby`

Keyboard

SpaceEnterOn a Tree.DragHandle: pick the node up (reorderable), then drop it at the chosen position.
ArrowUpArrowDownWhile dragging a node: move the drop target to the previous / next visible node.
ArrowRightWhile dragging: nest the node inside the current target.
ArrowLeftWhile dragging: outdent — move the target up to the parent level.
EscapeCancel an in-progress node drag and restore.
A1.3.1Info and RelationshipsA1.4.1Use of ColorA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • The tree is a <ul role=tree> of <li role=treeitem>; a node's children are wrapped in a <ul role=group>. Each treeitem carries aria-level, aria-setsize, and aria-posinset computed from its position; parents carry aria-expanded (leaves do not).
  • Roving tabindex — exactly ONE node is tabbable (tabindex=0) at a time (the last-focused node, else the first enabled visible node); all others are -1. Tab enters/leaves the whole tree as a single stop.
  • Keyboard (APG Tree View): Up/Down move through visible nodes; Right expands a collapsed parent then descends; Left collapses an open parent then ascends to the parent; Home/End jump to the first/last visible node; type-ahead focuses the next node whose label matches; `*` expands every sibling parent at the current level.
  • Selection: `selectionMode="single"` sets aria-selected on one node (optionally following focus); `selectionMode="multiple"` sets aria-multiselectable on the tree and aria-selected per node, with Space/click toggle, Shift+Arrow / Shift+Click range extension, and ⌘/Ctrl+A select-all-visible.
  • `checkable` renders a real Checkbox per node (tabindex=-1, operated by Space or pointer). Under `cascade` (default), checking a parent conducts to all enabled descendants and a parent shows aria-checked=mixed when only some descendants are checked; conduction stops at disabled nodes. `cascade={false}` (checkStrictly) makes checks independent.
  • The chevron toggle is decorative (aria-hidden, tabindex=-1) — expansion is announced via the treeitem's aria-expanded and driven by the keyboard, so the toggle never becomes a second tab stop. `Tree.Actions` keep their own tab stops within the active node.
  • Filter-to-reveal hides non-matching nodes, force-expands the ancestors of matches, and highlights the matched substring; roving/keyboard operate over the filtered-visible set. Inline rename swaps the label for an input whose data-slot keeps the tree keyboard hands-off (Enter/blur commit, Escape cancels). Give the tree an accessible name via `label`, `aria-label`, or `aria-labelledby` (dev-warns if missing).
  • Drag-reparent (`reorderable`) drags via a real <button> `Tree.DragHandle` (accessible name required), so the node keeps its role=treeitem — a role=button on the row would break the tree model. Fully keyboard-operable: Space/Enter on the handle picks the node up; arrows move the drop target through the visible nodes; Right nests it inside the target, Left outdents it; Space/Enter drops and Escape cancels. Each pick-up/move/drop is announced to a polite live region; a node can never be dropped into its own subtree (cycle-safe).