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
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>
)
}
<script setup>
import * as tree from "@zag-js/tree-view"
import { normalizeProps, useMachine } from "@zag-js/vue"
import { computed } from "vue"
const collection = tree.collection({
nodeToValue: (node) => node.id,
nodeToString: (node) => node.name,
rootNode: {
id: "ROOT",
name: "",
children: [
{ id: "src", name: "src", children: [
{ id: "src/index.ts", name: "index.ts" },
]},
],
},
})
const service = useMachine(tree.machine, {
id: "tree-1",
collection,
})
const api = computed(() => tree.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">File system</label>
<div v-bind="api.getTreeProps()">
<!-- render nodes -->
</div>
</div>
</template>
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.
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>
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.
The tree data. Create it with tree.collection(...).
Whether one or many nodes can be selected at a time. Defaults to "single".
Node IDs to expand when first rendered (uncontrolled).
Controlled set of expanded node IDs.
onExpandedChange
(details: ExpandedChangeDetails) => void
Called when nodes are expanded or collapsed. Receives expandedValue, expandedNodes, and focusedValue.
Node IDs to select when first rendered (uncontrolled).
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.
Whether clicking a branch toggles its expanded state. Defaults to true.
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
| Part | Element | Description |
|---|
root | div | The outer container |
label | label | Accessible label for the tree |
tree | div | The role="tree" element |
branch | div | A node that has children |
branchControl | div | Clickable area of a branch (indicator + label) |
branchTrigger | button | The toggle button inside branch control |
branchIndicator | span | The expand/collapse chevron icon |
branchText | span | The text label of a branch |
branchContent | div | The collapsible area containing children |
branchIndentGuide | div | Visual indent line inside branch content |
item | div | A leaf node (no children) |
itemText | span | The text label of a leaf node |
itemIndicator | span | Optional indicator icon on a leaf |
nodeCheckbox | div | Checkbox element on a node |
nodeRenameInput | input | The 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
| Key | Description |
|---|
ArrowDown | Move focus to the next visible node |
ArrowUp | Move focus to the previous visible node |
ArrowRight | Expand a collapsed branch; move to first child of expanded branch |
ArrowLeft | Collapse an expanded branch; move to parent node |
Home | Move focus to the first node in the tree |
End | Move focus to the last visible node in the tree |
Enter / Space | Select the focused node |
F2 | Enter rename mode on the focused node |
Enter (in rename input) | Submit the new name |
Escape (in rename input) | Cancel the rename |
| Character keys | Jump to the next node whose label starts with the typed character (typeahead) |
* | Expand all siblings of the focused branch at the same level |