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.
@garn/ui/treeOn 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.
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.
npx garn-ui add tree- 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
| Selector | State | Description |
|---|---|---|
[data-expanded] | expanded | A parent node with its children revealed — the chevron rotates and the role=group renders (data-state=expanded/collapsed). |
[data-selected] | selected | A selected node (selection modes) — a tinted fill + aria-selected. |
[data-checked] | checked | The node's checkbox state in `checkable` mode — checked / mixed (indeterminate, some descendants checked) / unchecked. |
[data-disabled] | disabled | An inert node — skipped by keyboard nav, selection, and cascade conduction. |
[data-editable] | editable | A node that can be renamed in place (F2 / double-click opens the rename input). |
[data-dragging] | dragging | The node currently being dragged in a `reorderable` tree — dimmed while its drop target is chosen. |
[data-drop-zone] | drop-zone | The node under the drop cursor, carrying the projected zone (before / after / inside) — drives the insertion line or the inside-nest highlight. |
| Property | Token | Tier |
|---|---|---|
rowGap | --garn-gap-inline | semantic · air |
rowHeight | --garn-control-h-md | component · identity |
rowRadius | --garn-radius | semantic |
motion | --garn-motion-fast | semantic · 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
- 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
selectionModefor the active-node model and the orthogonalcheckablefor a containment checkbox tree (they can coexist). - Add
Tree.Search(orfilterValue) for find-in-tree, andloadable/loadingto lazy-load a subtree on first expand. - Enable
reorderable+onMovewith aTree.DragHandlein each row to let users drag nodes to a new parent/position (apply the move to your data inonMove). - Show
Tree.Skeletonwhile the tree loads and an <Empty> (via Tree.Empty) when it's cleared.
- 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
asChilddirectly 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
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
| SpaceEnter | On a Tree.DragHandle: pick the node up (reorderable), then drop it at the chosen position. |
| ArrowUpArrowDown | While dragging a node: move the drop target to the previous / next visible node. |
| ArrowRight | While dragging: nest the node inside the current target. |
| ArrowLeft | While dragging: outdent — move the target up to the parent level. |
| Escape | Cancel an in-progress node drag and restore. |
- 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).