Skip to content

Tree View

Displays hierarchical data in an expandable/collapsible tree structure.

  • Tree
  • Directory Tree

Overview

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:

  • File browsers and directory explorers
  • Navigation menus with nested categories (multi-level sidebar)
  • Organizational charts or entity hierarchies
  • JSON/XML data explorers in developer tools
  • Comment threads with nested replies

When NOT to use a Tree View:

  • For flat collections — use a List
  • For tabular data — use a Table with row grouping
  • For progressive disclosure of content sections — use an Accordion
  • For simple two-level navigation — use a Sidebar with expandable groups

Verify all text labels meet contrast requirements with the Contrast Checker, especially for deeply nested items that may have reduced visual emphasis.

Variants

Tree View Variants

VariantPurposeVisual Treatment
BasicSimple expand/collapse hierarchy.Indented items with expand/collapse chevrons. No selection.
SelectableSingle-item selection (e.g., file browser).Clickable items with highlight on selection.
CheckboxMulti-selection with parent-child propagation.Checkboxes on each node. Parent auto-checks/unchecks children. Indeterminate state for partial selection.
Rich contentNodes with icons, badges, and actions.File-type icons, status badges, context menu triggers per node.
Lazy-loadingLarge hierarchies loaded on demand.Spinner or skeleton shown while children load after expansion.
Flat treeVisually flat but logically hierarchical.No indentation lines; relies on indentation depth alone.

Indentation Styles

StyleDescription
Guides / Connecting linesVertical and horizontal lines connecting parent to children. Classic IDE/file-explorer style.
Indent onlyPadding-based indentation without lines. Cleaner but harder to trace parentage in deep trees.
CompactReduced indentation (12–16px per level) for space-constrained panels.
Standard20–24px indentation per level for comfortable reading.

Node Types

TypeBehavior
BranchHas children. Displays expand/collapse chevron. Can be expanded or collapsed.
LeafNo children. No chevron. May be selectable.
Lazy branchHas children not yet loaded. Shows chevron and loads on expand.

Properties

Tree View Properties

PropertyTypeDefaultDescription
dataTreeNode[]—Hierarchical data structure
expandedstring[][]Controlled expanded node IDs
defaultExpandedstring[][]Initially expanded node IDs
selectedstring | string[]—Controlled selected node ID(s)
selectionMode'single' | 'multiple' | 'none''none'Selection behavior
checkboxSelectionbooleanfalseShow 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
indentationnumber20Pixels per nesting level
showGuidesbooleanfalseShow connecting guide lines
expandOnClickbooleantrueWhether clicking the node label expands/collapses

TreeNode Interface

PropertyTypeDescription
idstringUnique node identifier
labelstringDisplay text
iconReactNodeOptional icon for the node
childrenTreeNode[]Child nodes (empty array = leaf)
disabledbooleanPrevents interaction
isLoadingbooleanShows loading state (for async)

Token Mappings

Design Token Mappings

Token CategoryToken ExampleTree View Usage
Spacing – Indentation--space-5 (20px)Per-level indentation offset
Spacing – Item Padding--space-1 (4px) vertical, --space-2 (8px) horizontalNode content padding
Color – Surface--color-surfaceTree background
Color – Hover--color-surface-hoverNode hover state
Color – Selected--color-primary-100Selected node background
Color – Focus--color-primary-500Focus ring color
Color – Guide Lines--color-border-subtleConnecting lines between nodes
Color – Text--color-text-primaryNode label text
Color – Muted Text--color-text-secondaryMetadata or secondary info
Color – Chevron--color-text-tertiaryExpand/collapse icon
Typography--font-size-sm, --font-weight-normalNode label styling
Border Radius--radius-smSelected/hover item background rounding
Transition--duration-fastExpand/collapse animation, hover transitions
Icon Size16px / 20pxNode icon and chevron size

States

Tree View States

StateVisual ChangeNotes
CollapsedChevron points right (▸). Children hidden.Default state for branch nodes.
ExpandedChevron rotates down (▾). Children visible with indentation.Apply a smooth height transition or use display toggle for performance.
HoverSubtle background tint on the hovered node row.Only on interactive nodes.
FocusedVisible focus indicator (ring or background highlight).Follows roving tabindex pattern — single tab stop for the entire tree.
SelectedPrimary-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".
DisabledMuted text and icon. No hover/click response.aria-disabled="true". Skip during keyboard navigation.
LoadingSpinner replaces chevron or appears inline.For lazy-loaded children. Announce via aria-busy="true" on the node.
Empty branchExpanded 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.

Accessibility

Accessibility

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:

  • Root container: role="tree"
  • Each node: role="treeitem"
  • Each group of children: role="group"
  • Branch nodes: aria-expanded="true|false"
  • Nesting level: aria-level="N" (1-indexed)
  • Position in set: aria-setsize and aria-posinset

Example 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:

  • ↑ / ↓: Move focus to previous/next visible node
  • →: On collapsed branch → expand. On expanded branch → move to first child. On leaf → nothing.
  • ←: On expanded branch → collapse. On collapsed branch or leaf → move to parent.
  • Home: Move focus to first node in tree
  • End: Move focus to last visible node
  • Enter: Activate/select the focused node
  • Space: Toggle selection (or checkbox) on focused node
  • *** (asterisk)**: Expand all siblings of the focused node
  • Type-ahead: Typing characters moves focus to next node starting with that character

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.

Usage Guidelines

Usage Guidelines

Do:

  • Implement the full WAI-ARIA tree keyboard pattern — partial implementations frustrate keyboard users
  • Use aria-level, aria-setsize, and aria-posinset on every treeitem
  • Show connecting guide lines for trees deeper than 3 levels to help users trace parentage
  • Provide a way to expand/collapse all nodes for large trees
  • Lazy-load children for trees with potentially thousands of nodes

Don't:

  • Don't nest more than 6–8 levels — if your hierarchy is deeper, consider a breadcrumb + list pattern
  • Don't auto-expand all nodes on load for large trees — this overwhelms users and hurts performance
  • Don't use a tree view for simple two-level menus — a Sidebar with expandable sections is simpler
  • Don't forget the asterisk key (*) for expanding siblings — it's part of the ARIA spec and power users expect it
  • Don't mix selectable and non-selectable nodes in the same tree without clear visual differentiation

Performance:

  • Virtualize trees exceeding ~500 visible nodes
  • Debounce expand/collapse animations for rapid keyboard navigation
  • For lazy loading, cache previously loaded children to avoid redundant network requests

Code Snippets

html
<!-- 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>
tsx
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>
  );
}

Design Systems

Design System Implementations

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.

Data DisplayTreeDirectory Tree