Skip to content
Primitiv home
Framework
Consumption mode

Tree

stableSource Figma

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

Density

Preview

src
index.ts
app.tsx
README.md
package.json
Size
Connectors
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>

Density is set by a data-density ancestor — the Context system, not a Tree prop.

Installation

npx primitiv add tree

Import

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>

Props

Tree.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
children (required)ReactNode—headless
defaultExpandedValuesstring[]—headlessBranch values expanded on first render when uncontrolled. Omit for a fully-collapsed tree.
defaultSelectedValuestring | null—headlessThe value selected on first render when uncontrolled. null selects nothing.
defaultSelectedValuesstring[]—headlessThe values selected on first render when uncontrolled.
expandedValuesstring[]—headlessThe set of expanded branch values, owned by the consumer. Keep in sync via onExpandedChange.
onExpandedChange(values: string[]) => void—headlessCalled 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—headlessCalled 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—headlessCalled 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.
selectedValuestring | null—headlessThe selected value, owned by the consumer (or null for no selection). Keep it in sync via onSelectedValueChange.
selectedValuesstring[]—headlessThe selected values, owned by the consumer. Keep in sync via onSelectedValuesChange.
selectionMode"single" | "multiple""single"headlessAt 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"mdstyledRow size for the whole tree; data-density scales each size further.
connectors"lines" | "none"linesstyledWhether 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headless
value (required)string—headlessStable identifier for this item, unique within the tree. Links the item to selection / expansion state and to its TreePathSegment in a resolved path.
asChildbooleanfalseheadlessRender 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.
disabledbooleanfalseheadlessDisable selection and remove the item from roving navigation (Home/End still skip past it to a non-disabled neighbour).
labelstring—headlessOptional 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headless
value (required)string—headlessStable identifier for this branch, unique within the tree. Also seeds the parentValue of its TreeBranchContent's children and the derived aria-labelledby control id.
disabledbooleanfalseheadlessDisable selection, expansion-toggling, and roving navigation for this branch. The branch and its current content remain rendered.
labelstring—headlessOptional 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headless
asChildbooleanfalseheadlessRender 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headless
forceMountbooleanfalseheadlessKeep 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.

PropTypeDefaultFromDescription
childrenReactNode | ((args: TreeSelectionPathRenderProps) => ReactNode)—headlessEither 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.
separatorReactNode—headlessNode 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-slack

Item

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

Keyboard

KeyBehaviour
ArrowDown / ArrowUpMove focus to the next / previous visible row.
ArrowRightOn a collapsed branch, expand it; on an open branch, move to its first child. A no-op on a leaf.
ArrowLeftOn an open branch, collapse it; otherwise move focus to the parent branch. A no-op at the root.
Home / EndFocus the first / last visible row.
Enter / SpaceSelect the focused row. On a branch this also toggles its expansion in the same gesture.

Data attributes

Tree

className: .primitiv-tree

AttributeValueWhen
data-selection-modesingle | multipleselectionMode is single / selectionMode is multiple

TreeItem

className: .primitiv-tree__item

AttributeValueWhen
data-leaf""always
data-depth{n}always
data-selected""selected
data-disabled""disabled

TreeBranch

className: .primitiv-tree__branch

AttributeValueWhen
data-branch""always
data-depth{n}always
data-stateopen | closedexpanded / collapsed
data-selected""selected
data-disabled""disabled

TreeBranchContent

className: .primitiv-tree__branch-content

AttributeValueWhen
data-depth{n}always
data-stateopen | closedexpanded / collapsed

TreeBranchIndicator

className: .primitiv-tree__branch-indicator

AttributeValueWhen
data-stateopen | closedexpanded / collapsed

TreeSelectionPath

className: .primitiv-tree__selection-path

AttributeValueWhen
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 a treeitem carrying aria-level, aria-selected, and aria-expanded on 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.
  • disabled rows 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.BranchIndicator is decorative (aria-hidden) — the expanded state is announced through aria-expanded on the branch, so a labelled chevron would say it twice.
  • A collapsed Tree.BranchContent is 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.SelectionPath composes Breadcrumb, 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.

Density
src
index.ts
app.tsx
README.md
package.json
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>

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.

Density
src
components
Button.tsx
Card.tsx
index.ts
app.tsx
README.md
package.json
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>

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.

Density
src
index.ts
app.tsx
README.md
package.json
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>

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.

Density

Selected: index.ts

src
index.ts
app.tsx
README.md
package.json
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>

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.

Density
src
index.ts
app.tsx
README.md
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>