Tree
A composable, headless primitive for hierarchical data with selection and expand/collapse — plus registry recipes for multi-select and virtualized large-dataset pickers.
- Components
- Button
- Inputs
- Text Field
- Checkbox
- Foundation
- Color
- Spacing
Tree is a composition-first primitive: parts you assemble into navigation, filter, permission,
and picker trees. It owns selection, expansion, and styling — nothing else. Checkboxes, Select All,
search, dropdowns, and virtualization are recipes you layer on top, not props on the primitive.
Two of those recipes ship in the registry and are documented in Examples below.
Installation
Exported from @by/experience-system:
pnpm add @by/experience-systemIn this monorepo, depend on the workspace package (e.g. workspace:*) so imports resolve to packages/experience-system.
Composition
Tree
└── TreeItem (one per node, unique `value`)
├── TreeItemTrigger ← the clickable row
│ ├── TreeIndicator ← chevron on branches, spacer on leaves
│ ├── TreeItemContent → TreeItemLabel
│ └── TreeItemActions ← optional, on hover/selection
└── TreeItemGroup ← branches only; holds child TreeItemsA node is a branch when it contains a TreeItemGroup, otherwise a leaf. Depth and indentation are
derived from nesting. Built on the Collapsible primitive.
Usage
Tree is a client component. Render branches recursively, giving every TreeItem a unique value:
import {
Tree,
TreeItem,
TreeItemTrigger,
TreeIndicator,
TreeItemGroup,
TreeItemContent,
TreeItemLabel,
} from '@by/experience-system';
<Tree selectionMode="single" defaultExpandedValues={['components']}>
<TreeItem value="components">
<TreeItemTrigger>
<TreeIndicator />
<TreeItemContent>
<TreeItemLabel>Components</TreeItemLabel>
</TreeItemContent>
</TreeItemTrigger>
<TreeItemGroup>
<TreeItem value="button">
<TreeItemTrigger>
<TreeIndicator />
<TreeItemContent>
<TreeItemLabel>Button</TreeItemLabel>
</TreeItemContent>
</TreeItemTrigger>
</TreeItem>
</TreeItemGroup>
</TreeItem>
</Tree>;Key concepts
- Composition over configuration. The primitive ships no checkbox, Select All, or search. Keeping them out of the library is what lets you adapt them freely — they are recipe code you own.
- Selection is arrays of
valuestrings —selectedValues/expandedValues(controlled) ordefaultSelectedValues/defaultExpandedValues(uncontrolled). - The row parts work in any topology.
TreeItemTriggerandTreeIndicatorread the nested context by default, but every contextual input is also an optional prop (depth,selected,hasChildren,expanded,size). Supply them and a row renders standalone — in flat windowed lists, tree-grids, drag previews, or search results. Prop wins, context is the fallback.
Examples
Multi-select tree
A multi-select tree picker inside a Popover. Selected leaf names are summarised in the trigger. Supports optional search filtering and a Select All row. Built as a registry recipe on top of the Tree primitive — checkboxes, tri-state branches, and debounced search live in the recipe, not in the primitive, so you can change any of them without fighting the library. Good for datasets you would comfortably browse: hundreds of nodes.
Installation
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/multiselect-tree.tsx and installs the runtime dependencies (@by/experience-system, …) automatically:
pnpm dlx shadcn@latest add @by-es/multiselect-treeIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Virtualized multi-select tree
The same picker for large datasets (tens of thousands to ~100k nodes). Same row primitives, same visuals — the difference is topology: rows render as a flat, windowed list with TanStack Virtual, so only a viewport-worth of rows exists in the DOM no matter the data size. Ships debounced search, Select All, tri-state branches, and the full keyboard pattern (arrows, Home/End, typeahead, Ctrl/Cmd+A). The general windowing pattern is on Foundation → Virtualization.
Installation
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/virtualized-multiselect-tree.tsx and installs the runtime dependencies (@by/experience-system, …) automatically:
pnpm dlx shadcn@latest add @by-es/virtualized-multiselect-treeIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Requires @tanstack/react-virtual alongside
@by/experience-system.
At scale
Each concern scales differently, and the block handles all three:
- Rendering is constant-cost — windowing keeps ~15 rows in the DOM at any dataset size.
- Selection never scans: membership is a
Setlookup and branch tri-state reads a prebuilt index. Avoidarray.includesper row — that is the trap that freezes trees in the low thousands. - Search is the pressure point: filtering touches every node on each keystroke, so the block debounces it. Smooth to ~100k nodes, and the first thing to replace beyond that.
You own the copy, so every concern is a seam you can replace when your data outgrows the defaults:
| Seam | Swap for | When |
|---|---|---|
filterNodes (client search) | server or prebuilt-index search | data is remote or too big to scan |
data prop (whole tree in memory) | lazy loading — fetch children on expand | the tree is server-driven |
selection Set of ids | "all matching <filter>" + exceptions | Select All must cross the wire |
| branch tri-state recount | incremental selectedCountByBranch | one branch spans 100k+ leaves |
For server-driven data, combine lazy loading with windowing — see Virtualization vs lazy loading.
Behavior notes: while a search is active, toggling a branch selects only the visible (matching)
leaves; Select All always targets the whole dataset. Windowed rows expose aria-level and
per-sibling-set aria-setsize / aria-posinset computed from the data, and focus follows the window
via scrollToIndex.
Selection is uncontrolled by default. Pass selected (a Set of leaf ids) and onSelectedChange to
drive it from app state or an external store — the same pair works on the useMultiSelectTree hook if
you render your own rows.
API Reference
Titles name the exports from @by/experience-system.
Tree
Root (<ul role="tree">). Manages selection and expansion, passed to items via context.
| Prop | Type | Default |
|---|---|---|
selectionMode | "single" | "multiple" | "single" |
selectedValues / defaultSelectedValues | string[] | — / [] |
onSelectedValuesChange | (values: string[]) => void | — |
expandedValues / defaultExpandedValues | string[] | — / [] |
onExpandedValuesChange | (values: string[]) => void | — |
size | TreeSize ("sm" | "md" | "lg") | "md" |
aria-multiselectable is set when selectionMode="multiple". data-slot: tree.
TreeItem
A node (<li role="treeitem">). Becomes a branch when it contains a TreeItemGroup.
| Prop | Type | Default |
|---|---|---|
value | string (unique, required) | — |
disabled | boolean | false |
Exposes data-selected, data-disabled, data-depth, and aria-expanded (branches). data-slot: tree-item.
TreeItemTrigger
The clickable row; renders a <button> on leaves and a CollapsibleTrigger on branches. Every prop is
optional and falls back to the surrounding TreeItem / Tree context — supply them to render the row
standalone. Standalone rows also accept asChild to project the row styling onto a custom element —
use it when the row must host interactive children (e.g. a checkbox), since buttons cannot contain
other controls:
| Prop | Type | Drives |
|---|---|---|
depth | number | indentation (--tree-depth; consumer style merges with it) |
selected | boolean | selection styling; emits data-selected when explicitly true |
hasChildren | boolean | branch affordances |
expanded | boolean | aria-expanded / data-state — only outside a TreeItem, together with hasChildren |
size | TreeSize | row padding and text size |
Other parts
TreeIndicator— animated chevron on branches, a same-size spacer on leaves so labels align. Accepts the same optionalhasChildren/expanded/sizeprops for standalone rows.TreeItemGroup— the collapsible child list; mounting it marks the parent as a branch.TreeItemContent/TreeItemLabel— layout row and truncating label inside the trigger.TreeItemActions— optional controls shown on hover or when the row is selected.
Accessibility
Uses the WAI-ARIA Tree View roles and states —
tree / treeitem / group, aria-selected, aria-expanded, aria-multiselectable. Branch triggers
toggle on Space / Enter via the underlying Collapsible. The
pattern's arrow-key navigation (roving focus, Home/End, typeahead) is not yet implemented in nested
mode — rows are individually tabbable; the
virtualized recipe implements the full keyboard pattern in windowed
mode, and its windowed ARIA is described under At scale.
Source: packages/experience-system/src/components/Tree/Tree.tsx.