Loading…
Loading…
Displays hierarchical data in an expandable/collapsible tree structure.
The Tree View displays hierarchical data as an expandable and collapsible tree structure. It enables users to navigate nested relationships — file systems, organizational charts, category taxonomies, navigation menus — where understanding parent-child relationships is essential to the task.
Tree views are among the most complex interactive components in a design system. They combine deep nesting, recursive rendering, keyboard navigation across multiple axes (vertical traversal + horizontal expand/collapse), selection models (single, multi, checkbox), and potentially asynchronous data loading. Getting the ARIA implementation right is particularly challenging due to the depth of the role="tree" / role="treeitem" / role="group" specification.
When to use a Tree View:
When NOT to use a Tree View:
Verify all text labels meet contrast requirements with the Contrast Checker, especially for deeply nested items that may have reduced visual emphasis.
| Variant | Purpose | Visual Treatment |
|---|---|---|
| Basic | Simple expand/collapse hierarchy. | Indented items with expand/collapse chevrons. No selection. |
| Selectable | Single-item selection (e.g., file browser). | Clickable items with highlight on selection. |
| Checkbox | Multi-selection with parent-child propagation. | Checkboxes on each node. Parent auto-checks/unchecks children. Indeterminate state for partial selection. |
| Rich content | Nodes with icons, badges, and actions. | File-type icons, status badges, context menu triggers per node. |
| Lazy-loading | Large hierarchies loaded on demand. | Spinner or skeleton shown while children load after expansion. |
| Flat tree | Visually flat but logically hierarchical. | No indentation lines; relies on indentation depth alone. |
| Style | Description |
|---|---|
| Guides / Connecting lines | Vertical and horizontal lines connecting parent to children. Classic IDE/file-explorer style. |
| Indent only | Padding-based indentation without lines. Cleaner but harder to trace parentage in deep trees. |
| Compact | Reduced indentation (12–16px per level) for space-constrained panels. |
| Standard | 20–24px indentation per level for comfortable reading. |
| Type | Behavior |
|---|---|
| Branch | Has children. Displays expand/collapse chevron. Can be expanded or collapsed. |
| Leaf | No children. No chevron. May be selectable. |
| Lazy branch | Has children not yet loaded. Shows chevron and loads on expand. |
| Property | Type | Default | Description |
|---|---|---|---|
data | TreeNode[] | — | Hierarchical data structure |
expanded | string[] | [] | Controlled expanded node IDs |
defaultExpanded | string[] | [] | Initially expanded node IDs |
selected | string | string[] | — | Controlled selected node ID(s) |
selectionMode | 'single' | 'multiple' | 'none' | 'none' | Selection behavior |
checkboxSelection | boolean | false | Show checkboxes on each node |
onExpand | (nodeId: string, isExpanded: boolean) => void | — | Expand/collapse callback |
onSelect | (nodeId: string | string[]) => void | — | Selection callback |
onLoadChildren | (nodeId: string) => Promise<TreeNode[]> | — | Async child loading function |
indentation | number | 20 | Pixels per nesting level |
showGuides | boolean | false | Show connecting guide lines |
expandOnClick | boolean | true | Whether clicking the node label expands/collapses |
| Property | Type | Description |
|---|---|---|
id | string | Unique node identifier |
label | string | Display text |
icon | ReactNode | Optional icon for the node |
children | TreeNode[] | Child nodes (empty array = leaf) |
disabled | boolean | Prevents interaction |
isLoading | boolean | Shows loading state (for async) |
| Token Category | Token Example | Tree View Usage |
|---|---|---|
| Spacing – Indentation | --space-5 (20px) | Per-level indentation offset |
| Spacing – Item Padding | --space-1 (4px) vertical, --space-2 (8px) horizontal | Node content padding |
| Color – Surface | --color-surface | Tree background |
| Color – Hover | --color-surface-hover | Node hover state |
| Color – Selected | --color-primary-100 | Selected node background |
| Color – Focus | --color-primary-500 | Focus ring color |
| Color – Guide Lines | --color-border-subtle | Connecting lines between nodes |
| Color – Text | --color-text-primary | Node label text |
| Color – Muted Text | --color-text-secondary | Metadata or secondary info |
| Color – Chevron | --color-text-tertiary | Expand/collapse icon |
| Typography | --font-size-sm, --font-weight-normal | Node label styling |
| Border Radius | --radius-sm | Selected/hover item background rounding |
| Transition | --duration-fast | Expand/collapse animation, hover transitions |
| Icon Size | 16px / 20px | Node icon and chevron size |
| State | Visual Change | Notes |
|---|---|---|
| Collapsed | Chevron points right (▸). Children hidden. | Default state for branch nodes. |
| Expanded | Chevron rotates down (▾). Children visible with indentation. | Apply a smooth height transition or use display toggle for performance. |
| Hover | Subtle background tint on the hovered node row. | Only on interactive nodes. |
| Focused | Visible focus indicator (ring or background highlight). | Follows roving tabindex pattern — single tab stop for the entire tree. |
| Selected | Primary-tinted background. | aria-selected="true" on the node. |
| Checked (checkbox mode) | Checkbox filled. Parent shows indeterminate (—) if partially checked. | aria-checked="true" / aria-checked="mixed". |
| Disabled | Muted text and icon. No hover/click response. | aria-disabled="true". Skip during keyboard navigation. |
| Loading | Spinner replaces chevron or appears inline. | For lazy-loaded children. Announce via aria-busy="true" on the node. |
| Empty branch | Expanded but shows "No items" or empty indicator. | Prevents confusion when a branch node has no children. |
| Drop target (drag & drop) | Highlighted border or background on the target node. | Keyboard alternative required for drag-and-drop reordering. |
Tree views have a dedicated ARIA pattern (role="tree") that is complex but well-specified. Incorrect implementation is extremely common.
WCAG 1.3.1 — Info and Relationships: The tree structure must be programmatically expressed:
role="tree"role="treeitem"role="group"aria-expanded="true|false"aria-level="N" (1-indexed)aria-setsize and aria-posinsetExample structure:
<ul role="tree" aria-label="File browser">
<li role="treeitem" aria-level="1" aria-setsize="3" aria-posinset="1" aria-expanded="true">
src/
<ul role="group">
<li role="treeitem" aria-level="2" aria-setsize="2" aria-posinset="1">index.ts</li>
<li role="treeitem" aria-level="2" aria-setsize="2" aria-posinset="2">utils.ts</li>
</ul>
</li>
</ul>
WCAG 2.1.1 — Keyboard: The WAI-ARIA TreeView pattern defines specific keyboard interactions:
The entire tree is a single tab stop (roving tabindex). This is critical — a 200-node tree with individual tab stops would be unusable.
WCAG 2.4.7 — Focus Visible: The focused tree item must have a clearly visible indicator. Verify focus ring contrast with the Contrast Checker (3:1 minimum per WCAG 1.4.11).
WCAG 4.1.2 — Name, Role, Value: Each treeitem must have an accessible name (from text content or aria-label). Branch nodes must communicate expanded/collapsed state via aria-expanded. Selected nodes use aria-selected="true".
Checkbox tree specifics: When using checkbox selection with parent-child propagation, the parent's indeterminate state must be communicated via aria-checked="mixed". Screen readers will announce "partially checked." Ensure checking a parent checks all descendants, and unchecking a parent unchecks all descendants — this is the expected behavior users rely on.
Do:
aria-level, aria-setsize, and aria-posinset on every treeitemDon't:
Performance:
<!-- Basic tree view -->
<ul role="tree" aria-label="Project files">
<li role="treeitem" aria-expanded="true" aria-level="1"
aria-setsize="2" aria-posinset="1" tabindex="0">
<div class="tree__node">
<button class="tree__toggle" aria-hidden="true" tabindex="-1">
<svg class="tree__chevron tree__chevron--expanded"><!-- chevron --></svg>
</button>
<svg class="tree__icon" aria-hidden="true"><!-- folder icon --></svg>
<span class="tree__label">src</span>
</div>
<ul role="group">
<li role="treeitem" aria-level="2" aria-setsize="3"
aria-posinset="1" tabindex="-1">
<div class="tree__node">
<span class="tree__toggle-spacer"></span>
<svg class="tree__icon" aria-hidden="true"><!-- file icon --></svg>
<span class="tree__label">index.ts</span>
</div>
</li>
<li role="treeitem" aria-level="2" aria-setsize="3"
aria-posinset="2" tabindex="-1">
<div class="tree__node">
<span class="tree__toggle-spacer"></span>
<svg class="tree__icon" aria-hidden="true"><!-- file icon --></svg>
<span class="tree__label">App.tsx</span>
</div>
</li>
<li role="treeitem" aria-expanded="false" aria-level="2"
aria-setsize="3" aria-posinset="3" tabindex="-1">
<div class="tree__node">
<button class="tree__toggle" aria-hidden="true" tabindex="-1">
<svg class="tree__chevron"><!-- chevron --></svg>
</button>
<svg class="tree__icon" aria-hidden="true"><!-- folder icon --></svg>
<span class="tree__label">components</span>
</div>
<!-- children hidden when aria-expanded="false" -->
</li>
</ul>
</li>
<li role="treeitem" aria-level="1" aria-setsize="2"
aria-posinset="2" tabindex="-1">
<div class="tree__node">
<span class="tree__toggle-spacer"></span>
<svg class="tree__icon" aria-hidden="true"><!-- file icon --></svg>
<span class="tree__label">package.json</span>
</div>
</li>
</ul>
<style>
[role="tree"] {
list-style: none;
padding: 0;
margin: 0;
font-size: var(--font-size-sm);
}
[role="group"] {
list-style: none;
padding: 0;
margin: 0;
}
.tree__node {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
}
.tree__node:hover {
background: var(--color-surface-hover);
}
[role="treeitem"][tabindex="0"] > .tree__node {
outline: 2px solid var(--color-primary-500);
outline-offset: -2px;
}
[role="treeitem"][aria-selected="true"] > .tree__node {
background: var(--color-primary-100);
}
.tree__toggle {
all: unset;
display: flex;
width: 16px;
height: 16px;
}
.tree__toggle-spacer {
width: 16px;
flex-shrink: 0;
}
.tree__chevron {
transition: transform var(--duration-fast);
}
.tree__chevron--expanded {
transform: rotate(90deg);
}
[role="group"] {
padding-left: 20px;
}
</style>interface TreeNode {
id: string;
label: string;
icon?: React.ReactNode;
children?: TreeNode[];
}
interface TreeViewProps {
data: TreeNode[];
label: string;
selected?: string;
onSelect?: (nodeId: string) => void;
}
function TreeView({ data, label, selected, onSelect }: TreeViewProps) {
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
const treeRef = React.useRef<HTMLUListElement>(null);
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const handleKeyDown = (e: React.KeyboardEvent, node: TreeNode, level: number) => {
const tree = treeRef.current;
if (!tree) return;
const items = Array.from(tree.querySelectorAll<HTMLElement>('[role="treeitem"]'));
const current = e.currentTarget as HTMLElement;
const idx = items.indexOf(current);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
items[idx + 1]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
items[idx - 1]?.focus();
break;
case 'ArrowRight':
e.preventDefault();
if (node.children?.length) {
if (!expanded.has(node.id)) toggleExpand(node.id);
else items[idx + 1]?.focus();
}
break;
case 'ArrowLeft':
e.preventDefault();
if (node.children?.length && expanded.has(node.id)) {
toggleExpand(node.id);
} else {
// Move to parent
const parentGroup = current.closest('[role="group"]');
const parentItem = parentGroup?.closest('[role="treeitem"]') as HTMLElement | null;
parentItem?.focus();
}
break;
case 'Enter':
case ' ':
e.preventDefault();
onSelect?.(node.id);
break;
case 'Home':
e.preventDefault();
items[0]?.focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1]?.focus();
break;
}
};
function renderNode(node: TreeNode, level: number, posInSet: number, setSize: number, isFirst: boolean) {
const isBranch = node.children && node.children.length > 0;
const isExpanded = expanded.has(node.id);
return (
<li
key={node.id}
role="treeitem"
aria-level={level}
aria-setsize={setSize}
aria-posinset={posInSet}
aria-expanded={isBranch ? isExpanded : undefined}
aria-selected={selected === node.id}
tabIndex={isFirst && level === 1 ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, node, level)}
onFocus={(e) => e.stopPropagation()}
>
<div
className={`tree__node${selected === node.id ? ' tree__node--selected' : ''}`}
onClick={() => {
if (isBranch) toggleExpand(node.id);
onSelect?.(node.id);
}}
>
{isBranch ? (
<span className={`tree__chevron${isExpanded ? ' tree__chevron--expanded' : ''}`}>▸</span>
) : (
<span className="tree__toggle-spacer" />
)}
{node.icon && <span className="tree__icon">{node.icon}</span>}
<span className="tree__label">{node.label}</span>
</div>
{isBranch && isExpanded && (
<ul role="group" style={{ paddingLeft: 20 }}>
{node.children!.map((child, i) =>
renderNode(child, level + 1, i + 1, node.children!.length, false)
)}
</ul>
)}
</li>
);
}
return (
<ul role="tree" aria-label={label} ref={treeRef} className="tree">
{data.map((node, i) => renderNode(node, 1, i + 1, data.length, i === 0))}
</ul>
);
}Material Design 3 provides TreeView through MUI's @mui/x-tree-view package (part of MUI X). It offers SimpleTreeView (static data) and RichTreeView (dynamic data with items prop). Key features include multiSelect, checkboxSelection (with parent-child cascading), onItemExpansionToggle, onItemSelectionToggle, and slots for customizing expand/collapse icons, item content, and group transitions. MUI's tree view implements the full WAI-ARIA tree keyboard pattern. The RichTreeView supports lazy loading via items updates and custom content rendering via slotProps.item. Material uses a slide/fade animation for expand/collapse.
Ant Design provides Tree with treeData (array of { title, key, children }), checkable (checkbox selection), selectable, expandedKeys, selectedKeys, checkedKeys, draggable (drag-and-drop reordering), showLine (connecting guide lines), showIcon, and loadData (async child loading). Ant's tree supports filterTreeNode for search highlighting — useful for file browsers. The Tree.DirectoryTree sub-component provides a file-explorer-specific variant with automatic icon assignment and double-click-to-expand behavior.
Radix UI does not provide a tree view primitive. This is a significant gap acknowledged by the Radix team — tree views are complex enough that a dedicated primitive would be valuable, but the ARIA pattern is challenging to abstract cleanly.
Headless UI does not provide a tree component.
react-arborist is a popular headless tree library that handles virtualization, drag-and-drop, inline renaming, and keyboard navigation. It renders using react-window for performance with large trees (10,000+ nodes) and is a strong choice when building custom tree views.
shadcn/ui does not ship a tree component as of early 2026 but the community has contributed several recipes using Radix Collapsible and recursive rendering patterns. These work for small trees but lack virtualization.
For trees with deeply nested content, verify label contrast at each indentation level using the Contrast Checker.