A hierarchical list of expandable branches and selectable leaves — the WAI-ARIA tree view, with connector guide lines and an optional breadcrumb of the selected node's ancestry.
Playground
Preview
import { Tree, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";
<Tree size="md" connectors="lines" defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <TreeBranch value="src" label="src"> <TreeBranchControl> <TreeBranchIndicator /> src </TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> {/* ...more items and nested branches */} </TreeBranchContent> </TreeBranch> <TreeItem value="readme" label="README.md">README.md</TreeItem></Tree>import { Tree } from "@primitiv-ui/react";
<Tree.Root defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <Tree.Branch value="src" label="src"> <Tree.BranchControl> <Tree.BranchIndicator /> src </Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> {/* ...more items and nested branches */} </Tree.BranchContent> </Tree.Branch> <Tree.Item value="readme" label="README.md">README.md</Tree.Item></Tree.Root>Density is set by a data-density ancestor — the Context system, not a Tree prop.
Installation
npx primitiv add treepnpm dlx primitiv add treeyarn dlx primitiv add treebunx primitiv add treeImport
import { Tree } from "@/components/ui/tree";Copied into your project as .primitiv-tree — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
<Tree> <TreeSelectionPath /> {/* optional — the ancestry breadcrumb */} <TreeBranch> <TreeBranchControl> <TreeBranchIndicator /> </TreeBranchControl> <TreeBranchContent> <TreeItem /> {/* a leaf, or another Branch */} </TreeBranchContent> </TreeBranch> <TreeItem /></Tree><Tree.Root> <Tree.SelectionPath /> {/* optional — the ancestry breadcrumb */} <Tree.Branch> <Tree.BranchControl> <Tree.BranchIndicator /> </Tree.BranchControl> <Tree.BranchContent> <Tree.Item /> {/* a leaf, or another Branch */} </Tree.BranchContent> </Tree.Branch> <Tree.Item /></Tree.Root>Props
Tree.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | |
| defaultExpandedValues | string[] | — | headless | Branch values expanded on first render when uncontrolled. Omit for a fully-collapsed tree. |
| defaultSelectedValue | string | null | — | headless | The value selected on first render when uncontrolled. null selects
nothing. |
| defaultSelectedValues | string[] | — | headless | The values selected on first render when uncontrolled. |
| expandedValues | string[] | — | headless | The set of expanded branch values, owned by the consumer. Keep in
sync via onExpandedChange. |
| onExpandedChange | (values: string[]) => void | — | headless | Called with the full set of expanded branch values whenever a branch is toggled. Optional in uncontrolled mode. Called with the full set of expanded branch values the user requested on each toggle. Required in controlled mode. |
| onSelectedValueChange | (value: string | null) => void | — | headless | Called with the newly-selected value (or null when cleared) on each
user selection. Optional in uncontrolled mode.
Called with the value the user requested to select (or null when
cleared). Required in controlled mode. |
| onSelectedValuesChange | (values: string[]) => void | — | headless | Called with the full selected set (in selection order) on each change. Optional in uncontrolled mode. Called with the full selected set (in selection order) the user requested. Required in controlled mode. |
| selectedValue | string | null | — | headless | The selected value, owned by the consumer (or null for no
selection). Keep it in sync via onSelectedValueChange. |
| selectedValues | string[] | — | headless | The selected values, owned by the consumer. Keep in sync via
onSelectedValuesChange. |
| selectionMode | "single" | "multiple" | "single" | headless | At most one node may be selected; see SelectionMode.
Many nodes may be selected via Ctrl/Cmd+click and Shift+click
ranges; see SelectionMode. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | md | styled | Row size for the whole tree; data-density scales each size further. |
| connectors | "lines" | "none" | lines | styled | Whether nested rows are joined to their parent by hairline guide lines. |
Tree.Item
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | |
| value | string | — | headless | Stable identifier for this item, unique within the tree. Links the
item to selection / expansion state and to its
TreePathSegment in a resolved path. |
| asChild | boolean | false | headless | Render the item as the supplied child element instead of <div>,
merging the treeitem behaviour onto it via the Slot pattern.
The child must accept a ref. |
| disabled | boolean | false | headless | Disable selection and remove the item from roving navigation
(Home/End still skip past it to a non-disabled neighbour). |
| label | string | — | headless | Optional display label for this item. Stored alongside the value
in the tree's node registry so useTreePath and
Tree.SelectionPath can surface it without an external lookup.
Has no effect on what Tree.Item renders. |
Tree.Branch
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | |
| value | string | — | headless | Stable identifier for this branch, unique within the tree. Also seeds
the parentValue of its TreeBranchContent's children and the
derived aria-labelledby control id. |
| disabled | boolean | false | headless | Disable selection, expansion-toggling, and roving navigation for this branch. The branch and its current content remain rendered. |
| label | string | — | headless | Optional display label for this branch. Stored alongside the value
in the tree's node registry so useTreePath and
Tree.SelectionPath can surface it without an external lookup.
Has no effect on what Tree.Branch renders. |
Tree.BranchControl
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | |
| asChild | boolean | false | headless | Render the control as the supplied child element instead of <div>,
merging its props (including the aria-labelledby id) onto the child
via the Slot pattern. The child must accept a ref. |
Tree.BranchContent
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | |
| forceMount | boolean | false | headless | Keep the content mounted while the branch is collapsed so CSS can
animate it in and out. When collapsed it is hidden from assistive
technology with aria-hidden and carries data-state="closed". |
Tree.BranchIndicator
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render as the supplied child element instead of <span>, merging
aria-hidden and data-state onto it via the Slot pattern
(e.g. an icon that rotates on data-state="open"). |
Tree.SelectionPath
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | ((args: TreeSelectionPathRenderProps) => ReactNode) | — | headless | Either standard React children (ignored — the subcomponent does its own rendering) or a render-prop receiving the resolved selection paths so consumers can lay out custom markup. |
| separator | ReactNode | — | headless | Node passed to each Breadcrumb.Separator in the default rendering.
Defaults to Breadcrumb's built-in "/" glyph. |
Styling contract
Control frame
--primitiv-tree-indent--primitiv-tree-focus-ring-radius--primitiv-tree-indicator-rotation--primitiv-tree-connector-color--primitiv-tree-connector-thickness--primitiv-tree-connector-elbow-height--primitiv-tree-connector-stub-width--primitiv-tree-connector-stub-width-leaf--primitiv-tree-connector-rail-offset--primitiv-tree-transition-duration--primitiv-tree-transition-easing--primitiv-tree-selection-path-gap--primitiv-tree-selection-path-current-font-weight--primitiv-tree-label-ink-slackItem
--primitiv-tree-item-height--primitiv-tree-item-padding-inline--primitiv-tree-item-gap--primitiv-tree-item-radius--primitiv-tree-item-icon-size--primitiv-tree-item-font-family--primitiv-tree-item-font-size--primitiv-tree-item-font-weight--primitiv-tree-item-line-height--primitiv-tree-item-color--primitiv-tree-item-bg--primitiv-tree-item-bg-hover--primitiv-tree-item-bg-selectedKeyboard
| Key | Behaviour |
|---|---|
| ArrowDown / ArrowUp | Move focus to the next / previous visible row. |
| ArrowRight | On a collapsed branch, expand it; on an open branch, move to its first child. A no-op on a leaf. |
| ArrowLeft | On an open branch, collapse it; otherwise move focus to the parent branch. A no-op at the root. |
| Home / End | Focus the first / last visible row. |
| Enter / Space | Select the focused row. On a branch this also toggles its expansion in the same gesture. |
Data attributes
Tree
className: .primitiv-tree
| Attribute | Value | When |
|---|---|---|
data-selection-mode | single | multiple | selectionMode is single / selectionMode is multiple |
Tree.Root
| Attribute | Value | When |
|---|---|---|
data-selection-mode | single | multiple | selectionMode is single / selectionMode is multiple |
TreeItem
className: .primitiv-tree__item
| Attribute | Value | When |
|---|---|---|
data-leaf | "" | always |
data-depth | {n} | always |
data-selected | "" | selected |
data-disabled | "" | disabled |
Tree.Item
| Attribute | Value | When |
|---|---|---|
data-leaf | "" | always |
data-depth | {n} | always |
data-selected | "" | selected |
data-disabled | "" | disabled |
TreeBranch
className: .primitiv-tree__branch
| Attribute | Value | When |
|---|---|---|
data-branch | "" | always |
data-depth | {n} | always |
data-state | open | closed | expanded / collapsed |
data-selected | "" | selected |
data-disabled | "" | disabled |
Tree.Branch
| Attribute | Value | When |
|---|---|---|
data-branch | "" | always |
data-depth | {n} | always |
data-state | open | closed | expanded / collapsed |
data-selected | "" | selected |
data-disabled | "" | disabled |
TreeBranchContent
className: .primitiv-tree__branch-content
| Attribute | Value | When |
|---|---|---|
data-depth | {n} | always |
data-state | open | closed | expanded / collapsed |
Tree.BranchContent
| Attribute | Value | When |
|---|---|---|
data-depth | {n} | always |
data-state | open | closed | expanded / collapsed |
TreeBranchIndicator
className: .primitiv-tree__branch-indicator
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | expanded / collapsed |
Tree.BranchIndicator
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | expanded / collapsed |
TreeSelectionPath
className: .primitiv-tree__selection-path
| Attribute | Value | When |
|---|---|---|
data-tree-selection-segment | "" | always, on each crumb of the path — how the row is found without depending on Breadcrumb's classes |
data-value | <node value> | the node that crumb points at |
data-tree-selection-path | "" | always |
data-empty | "" | nothing is selected |
Tree.SelectionPath
| Attribute | Value | When |
|---|---|---|
data-tree-selection-segment | "" | always, on each crumb of the path — how the row is found without depending on Breadcrumb's classes |
data-value | <node value> | the node that crumb points at |
data-tree-selection-path | "" | always |
data-empty | "" | nothing is selected |
Accessibility
- The Root is a
role="tree"; each row is atreeitemcarryingaria-level,aria-selected, andaria-expandedon branches — the WAI-ARIA tree view, so a screen reader announces the depth, the open/closed state and the selection. - The rows share one tab stop. Tab moves into the tree and then out of it; the arrow keys, Home and End move between rows, and left/right collapse and expand. A tree of real controls would make a keyboard user Tab through every node — the roving tabindex is what the pattern requires instead.
disabledrows stay in the DOM and in the roving order, so they remain discoverable by keyboard; they only stop taking selection and pointer events. A control a keyboard user cannot reach is one they never learn exists.Tree.BranchIndicatoris decorative (aria-hidden) — the expanded state is announced througharia-expandedon the branch, so a labelled chevron would say it twice.- A collapsed
Tree.BranchContentis hidden from assistive technology (aria-hidden) even though it stays mounted to animate, so a screen reader never reads a subtree that looks closed. Tree.SelectionPathcomposesBreadcrumb, inheriting its navigation semantics; the current node is distinguished by both weight and colour, since colour alone is easy to miss at the small end of the size ramp.
Examples
A file tree
The canonical shape: Tree.Branches and Tree.Item leaves, authored recursively. defaultExpandedValues seeds which branches start open and defaultSelectedValue which node starts selected (single selection is the default). Every node needs a value unique within the tree; label feeds the node registry that Tree.SelectionPath reads. The Tree.BranchIndicator needs no icon — it ships its own chevron and the stylesheet rotates it open.
import { Tree, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";
<Tree defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <TreeBranch value="src" label="src"> <TreeBranchControl> <TreeBranchIndicator /> src </TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> {/* ...more items and nested branches */} </TreeBranchContent> </TreeBranch> <TreeItem value="readme" label="README.md">README.md</TreeItem></Tree>import { Tree } from "@primitiv-ui/react";
<Tree.Root defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <Tree.Branch value="src" label="src"> <Tree.BranchControl> <Tree.BranchIndicator /> src </Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> {/* ...more items and nested branches */} </Tree.BranchContent> </Tree.Branch> <Tree.Item value="readme" label="README.md">README.md</Tree.Item></Tree.Root>Selection path
Tree.SelectionPath renders a breadcrumb of the selected node's root-to-leaf ancestry — the editor path bar. It reads the selection from context, so it needs no props beyond its size, and it composes the Breadcrumb component (installed with the tree). Select a node below and watch the trail update; the current node is marked by weight. Its size is the tree's size and maps one tier down, so the bar reads compact beside the rows.
import { Tree, TreeSelectionPath, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";
<Tree defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <TreeSelectionPath /> <TreeBranch value="src" label="src"> <TreeBranchControl> <TreeBranchIndicator /> src </TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> {/* ...more items and nested branches */} </TreeBranchContent> </TreeBranch> <TreeItem value="readme" label="README.md">README.md</TreeItem></Tree>import { Tree } from "@primitiv-ui/react";
<Tree.Root defaultExpandedValues={["src"]} defaultSelectedValue="index.ts"> <Tree.SelectionPath /> <Tree.Branch value="src" label="src"> <Tree.BranchControl> <Tree.BranchIndicator /> src </Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> {/* ...more items and nested branches */} </Tree.BranchContent> </Tree.Branch> <Tree.Item value="readme" label="README.md">README.md</Tree.Item></Tree.Root>Multiple selection
selectionMode="multiple" lets several nodes be selected at once — Ctrl/Cmd+click adds or removes one, Shift+click selects a contiguous range, and a plain click resets to a single node. Seed it with defaultSelectedValues. Tree.SelectionPath then renders one trail per selected node.
import { Tree, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";
<Tree selectionMode="multiple" defaultExpandedValues={["src"]} defaultSelectedValues={["index.ts", "app.tsx"]}> <TreeBranch value="src" label="src"> <TreeBranchControl> <TreeBranchIndicator /> src </TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> {/* ...more items and nested branches */} </TreeBranchContent> </TreeBranch> <TreeItem value="readme" label="README.md">README.md</TreeItem></Tree>import { Tree } from "@primitiv-ui/react";
<Tree.Root selectionMode="multiple" defaultExpandedValues={["src"]} defaultSelectedValues={["index.ts", "app.tsx"]}> <Tree.Branch value="src" label="src"> <Tree.BranchControl> <Tree.BranchIndicator /> src </Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> {/* ...more items and nested branches */} </Tree.BranchContent> </Tree.Branch> <Tree.Item value="readme" label="README.md">README.md</Tree.Item></Tree.Root>Controlled
Pass expandedValues / onExpandedChange and selectedValue / onSelectedValueChange and the parent owns the state — needed to drive expansion from elsewhere (the Expand all / Collapse all buttons here), to persist it, or to react to selection. Expansion and selection are independent axes, each controlled or uncontrolled on its own: mix a controlled selection with an uncontrolled defaultExpandedValues if that is all you need. selectedValue/selectedValues is only for the matching selectionMode — the two shapes are mutually exclusive.
Selected: index.ts
import { useState } from "react";import { Tree, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";import { Button } from "@/components/ui/button";
const [expanded, setExpanded] = useState(["src"]);const [selected, setSelected] = useState("index.ts");
<Button onClick={() => setExpanded(["src", "components", "public"])}>Expand all</Button><Button onClick={() => setExpanded([])}>Collapse all</Button>
<Tree expandedValues={expanded} onExpandedChange={setExpanded} selectedValue={selected} onSelectedValueChange={setSelected}> <TreeBranch value="src" label="src"> <TreeBranchControl> <TreeBranchIndicator /> src </TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> {/* ...more items and nested branches */} </TreeBranchContent> </TreeBranch> <TreeItem value="readme" label="README.md">README.md</TreeItem></Tree>import { useState } from "react";import { Tree } from "@primitiv-ui/react";import { Button } from "@/components/ui/button";
const [expanded, setExpanded] = useState(["src"]);const [selected, setSelected] = useState("index.ts");
<Button onClick={() => setExpanded(["src", "components", "public"])}>Expand all</Button><Button onClick={() => setExpanded([])}>Collapse all</Button>
<Tree.Root expandedValues={expanded} onExpandedChange={setExpanded} selectedValue={selected} onSelectedValueChange={setSelected}> <Tree.Branch value="src" label="src"> <Tree.BranchControl> <Tree.BranchIndicator /> src </Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> {/* ...more items and nested branches */} </Tree.BranchContent> </Tree.Branch> <Tree.Item value="readme" label="README.md">README.md</Tree.Item></Tree.Root>Disabled nodes
disabled on a Tree.Item or Tree.Branch dims it and stops it taking selection or (for a branch) toggling — but it stays in the DOM and in the roving order, so a keyboard user still finds it, and Home/End land on non-disabled neighbours. A disabled branch keeps its children rendered and does not disable them.
import { Tree, TreeBranch, TreeBranchControl, TreeBranchContent, TreeBranchIndicator, TreeItem } from "@/components/ui/tree";
<Tree defaultExpandedValues={["src"]}> <TreeBranch value="src" label="src"> <TreeBranchControl><TreeBranchIndicator />src</TreeBranchControl> <TreeBranchContent> <TreeItem value="index.ts" label="index.ts">index.ts</TreeItem> <TreeItem value="app.tsx" label="app.tsx" disabled>app.tsx</TreeItem> </TreeBranchContent> </TreeBranch> <TreeBranch value="public" label="public" disabled> <TreeBranchControl><TreeBranchIndicator />public</TreeBranchControl> <TreeBranchContent>{/* still rendered, not disabled */}</TreeBranchContent> </TreeBranch></Tree>import { Tree } from "@primitiv-ui/react";
<Tree.Root defaultExpandedValues={["src"]}> <Tree.Branch value="src" label="src"> <Tree.BranchControl><Tree.BranchIndicator />src</Tree.BranchControl> <Tree.BranchContent> <Tree.Item value="index.ts" label="index.ts">index.ts</Tree.Item> <Tree.Item value="app.tsx" label="app.tsx" disabled>app.tsx</Tree.Item> </Tree.BranchContent> </Tree.Branch> <Tree.Branch value="public" label="public" disabled> <Tree.BranchControl><Tree.BranchIndicator />public</Tree.BranchControl> <Tree.BranchContent>{/* still rendered, not disabled */}</Tree.BranchContent> </Tree.Branch></Tree.Root>