Skip to main content
A tree view displays hierarchical data — like a file system — with expandable and collapsible branch nodes and selectable leaf items. It follows the WAI-ARIA tree view pattern.

Features

  • Expandable and collapsible branch nodes
  • Single or multiple node selection
  • Programmatic expand, collapse, select, and focus control
  • Full keyboard navigation with typeahead
  • Indentation guides for visual depth
  • Checkbox support for branch and leaf nodes
  • Lazy loading of children on demand
  • Inline node renaming with validation

Installation

npm install @zag-js/tree-view @zag-js/react

Usage

Create the tree collection

Use tree.collection to create a typed tree structure. Provide nodeToValue and nodeToString accessors, and a rootNode that holds the top-level items as children.
import * as tree from "@zag-js/tree-view"

interface Node {
  id: string
  name: string
  children?: Node[]
}

const collection = tree.collection<Node>({
  nodeToValue: (node) => node.id,
  nodeToString: (node) => node.name,
  rootNode: {
    id: "ROOT",
    name: "",
    children: [
      {
        id: "node_modules",
        name: "node_modules",
        children: [
          { id: "node_modules/zag-js", name: "zag-js" },
          { id: "node_modules/pandacss", name: "panda" },
          {
            id: "node_modules/@types",
            name: "@types",
            children: [
              { id: "node_modules/@types/react", name: "react" },
              { id: "node_modules/@types/react-dom", name: "react-dom" },
            ],
          },
        ],
      },
      { id: "src", name: "src", children: [
        { id: "src/index.ts", name: "index.ts" },
      ]},
      { id: "package.json", name: "package.json" },
    ],
  },
})

Render the tree view

import * as tree from "@zag-js/tree-view"
import { normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"

interface Node {
  id: string
  name: string
  children?: Node[]
}

function TreeNode({ node, indexPath, api }: {
  node: Node
  indexPath: number[]
  api: ReturnType<typeof tree.connect>
}) {
  const nodeProps = { node, indexPath }
  const nodeState = api.getNodeState(nodeProps)

  if (nodeState.isBranch) {
    return (
      <div {...api.getBranchProps(nodeProps)}>
        <div {...api.getBranchControlProps(nodeProps)}>
          <span {...api.getBranchIndicatorProps(nodeProps)}>â–¶</span>
          <span {...api.getBranchTextProps(nodeProps)}>{node.name}</span>
        </div>
        <div {...api.getBranchContentProps(nodeProps)}>
          <div {...api.getBranchIndentGuideProps(nodeProps)} />
          {node.children?.map((child, index) => (
            <TreeNode
              key={child.id}
              node={child}
              indexPath={[...indexPath, index]}
              api={api}
            />
          ))}
        </div>
      </div>
    )
  }

  return (
    <div {...api.getItemProps(nodeProps)}>
      <span {...api.getItemTextProps(nodeProps)}>{node.name}</span>
    </div>
  )
}

export function TreeView() {
  const service = useMachine(tree.machine, {
    id: useId(),
    collection,
  })

  const api = tree.connect(service, normalizeProps)

  return (
    <div {...api.getRootProps()}>
      <label {...api.getLabelProps()}>File system</label>
      <div {...api.getTreeProps()}>
        {collection.rootNode.children?.map((node, index) => (
          <TreeNode
            key={node.id}
            node={node}
            indexPath={[index]}
            api={api}
          />
        ))}
      </div>
    </div>
  )
}

Selection modes

The tree view supports single selection (default) and multiple selection.
// Single selection (default)
const service = useMachine(tree.machine, {
  collection,
  selectionMode: "single",
})

// Multiple selection
const service = useMachine(tree.machine, {
  collection,
  selectionMode: "multiple",
})

Default expanded and selected nodes

Set initial state without controlling it:
const service = useMachine(tree.machine, {
  collection,
  defaultExpandedValue: ["node_modules", "node_modules/@types"],
  defaultSelectedValue: ["node_modules/zag-js"],
})

Controlled state

Use controlled props when expansion or selection is managed externally:
const [expandedValue, setExpandedValue] = useState(["node_modules"])
const [selectedValue, setSelectedValue] = useState([])

const service = useMachine(tree.machine, {
  collection,
  expandedValue,
  selectedValue,
  onExpandedChange(details) {
    setExpandedValue(details.expandedValue)
  },
  onSelectionChange(details) {
    setSelectedValue(details.selectedValue)
  },
})

Programmatic control

The connected API exposes methods for imperative control:
// Expand and collapse
api.expand(["node_modules"])         // expand specific nodes
api.expand()                         // expand all nodes
api.collapse(["node_modules/@types"]) // collapse specific nodes
api.collapse()                       // collapse all nodes

// Selection
api.select(["node_modules/zag-js"])
api.deselect(["node_modules/zag-js"])
api.select()                         // select all nodes

// Focus
api.focus("node_modules/zag-js")

// Parent traversal
api.selectParent("node_modules/zag-js")
api.expandParent("node_modules/zag-js")

Indentation guides

Render a visual guide line inside branch content using getBranchIndentGuideProps:
<div {...api.getBranchContentProps(nodeProps)}>
  <div {...api.getBranchIndentGuideProps(nodeProps)} />
  {/* child nodes */}
</div>

Branch click behavior

By default, clicking a branch toggles it. Set expandOnClick: false to disable this:
const service = useMachine(tree.machine, {
  collection,
  expandOnClick: false,
})

Typeahead

By default, typing characters moves focus to the matching node. Disable typeahead:
const service = useMachine(tree.machine, {
  collection,
  typeahead: false,
})

Lazy loading

Load a branch’s children on demand to improve initial render time. Provide loadChildren (async), onLoadChildrenComplete to update the collection, and onLoadChildrenError to handle failures. Set childrenCount on branch nodes that have not yet loaded their children so the machine knows they are expandable:
const [collection, setCollection] = useState(
  tree.collection({
    nodeToValue: (node) => node.id,
    nodeToString: (node) => node.name,
    rootNode: {
      id: "ROOT",
      name: "",
      children: [
        { id: "node_modules", name: "node_modules", childrenCount: 3 },
        { id: "src", name: "src", childrenCount: 2 },
      ],
    },
  }),
)

const service = useMachine(tree.machine, {
  id: useId(),
  collection,
  async loadChildren({ valuePath, signal }) {
    const response = await fetch(`/api/tree/${valuePath.join("/")}`, { signal })
    const data = await response.json()
    return data.children
  },
  onLoadChildrenComplete({ collection }) {
    setCollection(collection)
  },
  onLoadChildrenError({ nodes }) {
    console.error("Failed to load children:", nodes)
  },
})

Inline renaming

The tree view supports renaming node labels inline. Press F2 on a focused node to enter rename mode.
1

Render the rename input inside each node

<div {...api.getItemProps(nodeProps)}>
  {nodeState.renaming ? (
    <input {...api.getNodeRenameInputProps(nodeProps)} />
  ) : (
    <span {...api.getItemTextProps(nodeProps)}>{node.name}</span>
  )}
</div>
2

Handle the rename complete callback to update your collection

const service = useMachine(tree.machine, {
  collection,
  onRenameComplete(details) {
    // details => { value: string, label: string, indexPath: number[] }
    setCollection(collection.updateNode(details.value, { name: details.label }))
  },
})
Validate renames with onBeforeRename (return false to reject):
const service = useMachine(tree.machine, {
  collection,
  onBeforeRename(details) {
    if (!details.label) return false // reject empty names
    return true
  },
})
Restrict which nodes can be renamed with canRename:
const service = useMachine(tree.machine, {
  collection,
  canRename(node) {
    return !node.children // only allow renaming leaf nodes
  },
})
Rename keyboard flow: F2 enters rename mode, Enter submits, Escape cancels. Blurring the input also submits.

Props

collection
TreeCollection<T>
The tree data. Create it with tree.collection(...).
selectionMode
"single" | "multiple"
Whether one or many nodes can be selected at a time. Defaults to "single".
defaultExpandedValue
string[]
Node IDs to expand when first rendered (uncontrolled).
expandedValue
string[]
Controlled set of expanded node IDs.
onExpandedChange
(details: ExpandedChangeDetails) => void
Called when nodes are expanded or collapsed. Receives expandedValue, expandedNodes, and focusedValue.
defaultSelectedValue
string[]
Node IDs to select when first rendered (uncontrolled).
selectedValue
string[]
Controlled set of selected node IDs.
onSelectionChange
(details: SelectionChangeDetails) => void
Called when the selection changes. Receives selectedValue, selectedNodes, and focusedValue.
onFocusChange
(details: FocusChangeDetails) => void
Called when keyboard focus moves to a different node.
expandOnClick
boolean
Whether clicking a branch toggles its expanded state. Defaults to true.
typeahead
boolean
Whether typing characters moves focus to matching nodes. Defaults to true.
loadChildren
(details: LoadChildrenDetails) => Promise<T[]>
Async function to load children for a branch node. Enables lazy loading.
onLoadChildrenComplete
(details: { collection: TreeCollection }) => void
Called after children are loaded. Use this to update your collection state.
onLoadChildrenError
(details: { nodes: NodeWithError[] }) => void
Called when loading children fails.
canRename
(node: T, indexPath: number[]) => boolean
Controls which nodes the user can rename. Return false to prevent renaming.
onBeforeRename
(details: RenameCompleteDetails) => boolean
Called before a rename is applied. Return false to reject the change.
onRenameComplete
(details: RenameCompleteDetails) => void
Called after a successful rename. Receives value (node ID), label (new name), and indexPath.

Styling

Every tree view part exposes a data-part attribute.

Parts reference

PartElementDescription
rootdivThe outer container
labellabelAccessible label for the tree
treedivThe role="tree" element
branchdivA node that has children
branchControldivClickable area of a branch (indicator + label)
branchTriggerbuttonThe toggle button inside branch control
branchIndicatorspanThe expand/collapse chevron icon
branchTextspanThe text label of a branch
branchContentdivThe collapsible area containing children
branchIndentGuidedivVisual indent line inside branch content
itemdivA leaf node (no children)
itemTextspanThe text label of a leaf node
itemIndicatorspanOptional indicator icon on a leaf
nodeCheckboxdivCheckbox element on a node
nodeRenameInputinputThe inline rename text input

State attributes

/* Branch expanded/collapsed */
[data-part="branch"][data-state="open"] { }
[data-part="branch"][data-state="closed"] { }
[data-part="branch-indicator"][data-state="open"] {
  transform: rotate(90deg);
}

/* Selected nodes */
[data-part="item"][data-selected] { background: blue; }
[data-part="branch"][data-selected] { background: blue; }

/* Focused node */
[data-part="item"][data-focused] { outline: 2px solid blue; }

/* Disabled node */
[data-part="item"][data-disabled] { opacity: 0.4; }

/* Node currently being renamed */
[data-part="item"][data-renaming] { }

/* Branch loading children */
[data-part="branch"][data-loading] { }

/* Depth-based indentation via CSS variable */
[data-part="item"],
[data-part="branch-control"] {
  padding-left: calc(var(--depth) * 16px);
}

Accessibility

Follows the Tree View WAI-ARIA design pattern. The tree uses role="tree", branches use role="group", and items use role="treeitem".

Keyboard interactions

KeyDescription
ArrowDownMove focus to the next visible node
ArrowUpMove focus to the previous visible node
ArrowRightExpand a collapsed branch; move to first child of expanded branch
ArrowLeftCollapse an expanded branch; move to parent node
HomeMove focus to the first node in the tree
EndMove focus to the last visible node in the tree
Enter / SpaceSelect the focused node
F2Enter rename mode on the focused node
Enter (in rename input)Submit the new name
Escape (in rename input)Cancel the rename
Character keysJump to the next node whose label starts with the typed character (typeahead)
*Expand all siblings of the focused branch at the same level

Build docs developers (and LLMs) love