Experience System

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-system

In 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 TreeItems

A 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 value stringsselectedValues / expandedValues (controlled) or defaultSelectedValues / defaultExpandedValues (uncontrolled).
  • The row parts work in any topology. TreeItemTrigger and TreeIndicator read 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-tree

In 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-tree

In 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 Set lookup and branch tri-state reads a prebuilt index. Avoid array.includes per 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:

SeamSwap forWhen
filterNodes (client search)server or prebuilt-index searchdata is remote or too big to scan
data prop (whole tree in memory)lazy loading — fetch children on expandthe tree is server-driven
selection Set of ids"all matching <filter>" + exceptionsSelect All must cross the wire
branch tri-state recountincremental selectedCountByBranchone 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.

PropTypeDefault
selectionMode"single" | "multiple""single"
selectedValues / defaultSelectedValuesstring[]— / []
onSelectedValuesChange(values: string[]) => void
expandedValues / defaultExpandedValuesstring[]— / []
onExpandedValuesChange(values: string[]) => void
sizeTreeSize ("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.

PropTypeDefault
valuestring (unique, required)
disabledbooleanfalse

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:

PropTypeDrives
depthnumberindentation (--tree-depth; consumer style merges with it)
selectedbooleanselection styling; emits data-selected when explicitly true
hasChildrenbooleanbranch affordances
expandedbooleanaria-expanded / data-state — only outside a TreeItem, together with hasChildren
sizeTreeSizerow padding and text size

Other parts

  • TreeIndicator — animated chevron on branches, a same-size spacer on leaves so labels align. Accepts the same optional hasChildren / expanded / size props 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.