// pattern

Accessible Tree View Pattern

A hierarchical tree view for navigating nested data like file systems, category lists, or organizational charts. Uses role="tree", role="treeitem", role="group", aria-expanded, and roving tabindex for full keyboard and screen reader support.

aria-required wcag-2.2-aa advanced

// 01 · live demo

Live Demo

Use Up/Down arrows to navigate, Right to expand, Left to collapse, Enter to select.

  • src
    • components
      • Button.tsx
      • Modal.tsx
      • TreeView.tsx
    • index.ts
    • App.tsx
  • package.json
  • tsconfig.json

// 02 · the code

The Code

HTML
<!-- Root: role="tree" with an accessible label -->
<ul role="tree" aria-label="Project files">

  <!-- Expandable folder node -->
  <li role="treeitem"
      aria-expanded="true"
      tabindex="0"
      aria-selected="false">
    <span class="tree-node">
      <svg class="tree-expand" aria-hidden="true">...</svg>
      <svg class="tree-icon" aria-hidden="true">...</svg>
      <span class="tree-label">src</span>
    </span>

    <!-- Children wrapped in role="group" -->
    <ul role="group">

      <!-- Nested folder -->
      <li role="treeitem"
          aria-expanded="false"
          tabindex="-1"
          aria-selected="false">
        <span class="tree-node">
          <svg class="tree-expand" aria-hidden="true">...</svg>
          <svg class="tree-icon" aria-hidden="true">...</svg>
          <span class="tree-label">components</span>
        </span>
        <ul role="group">
          <!-- Leaf file node (no aria-expanded) -->
          <li role="treeitem"
              tabindex="-1"
              aria-selected="false">
            <span class="tree-node">
              <svg class="tree-icon" aria-hidden="true">...</svg>
              <span class="tree-label">Button.tsx</span>
            </span>
          </li>
        </ul>
      </li>

      <!-- Leaf file node -->
      <li role="treeitem"
          tabindex="-1"
          aria-selected="false">
        <span class="tree-node">
          <svg class="tree-icon" aria-hidden="true">...</svg>
          <span class="tree-label">index.ts</span>
        </span>
      </li>

    </ul>
  </li>

</ul>
CSS
/* Tree container */
[role="tree"] {
  list-style: none;
  padding: 0;
  margin: 0;
}

[role="tree"] ul {
  list-style: none;
  padding-left: 1.5rem;
  margin: 0;
}

/* Hide children of collapsed nodes */
[aria-expanded="false"] > ul {
  display: none;
}

/* Tree node row */
.tree-node {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
  cursor: pointer;
  user-select: none;
  min-height: 2rem;
}

.tree-node:hover {
  background: rgba(0, 0, 0, 0.04);
}

/* Focus indicator on the treeitem, not the span */
[role="treeitem"]:focus {
  outline: none;
}

[role="treeitem"]:focus > .tree-node {
  outline: 2px solid #5b2a86;
  outline-offset: -2px;
  border-radius: 0.25rem;
}

/* Selected state */
[role="treeitem"][aria-selected="true"] > .tree-node {
  background: rgba(59, 130, 246, 0.1);
  color: #5b2a86;
}

/* Expand/collapse chevron */
.tree-expand {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
  transition: transform 0.15s ease;
}

[aria-expanded="true"] > .tree-node .tree-expand {
  transform: rotate(90deg);
}

/* Icons */
.tree-icon {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
}

.tree-label {
  flex: 1;
}
JavaScript
function initTree(treeEl) {
  // Collect all visible treeitems
  function getVisibleItems() {
    return [...treeEl.querySelectorAll('[role="treeitem"]')]
      .filter(item => {
        // Include if no ancestor treeitem is collapsed
        let parent = item.parentElement.closest('[role="treeitem"]');
        while (parent) {
          if (parent.getAttribute('aria-expanded') === 'false') return false;
          parent = parent.parentElement.closest('[role="treeitem"]');
        }
        return true;
      });
  }

  // Move focus with roving tabindex
  function focusItem(item) {
    const current = treeEl.querySelector('[tabindex="0"]');
    if (current) current.setAttribute('tabindex', '-1');
    item.setAttribute('tabindex', '0');
    item.focus();
  }

  // Select a treeitem
  function selectItem(item) {
    treeEl.querySelectorAll('[aria-selected="true"]')
      .forEach(el => el.setAttribute('aria-selected', 'false'));
    item.setAttribute('aria-selected', 'true');
  }

  treeEl.addEventListener('keydown', (e) => {
    const target = e.target.closest('[role="treeitem"]');
    if (!target) return;

    const items = getVisibleItems();
    const index = items.indexOf(target);

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (index < items.length - 1) focusItem(items[index + 1]);
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (index > 0) focusItem(items[index - 1]);
        break;

      case 'ArrowRight':
        e.preventDefault();
        if (target.getAttribute('aria-expanded') === 'false') {
          target.setAttribute('aria-expanded', 'true');
        } else if (target.getAttribute('aria-expanded') === 'true') {
          // Move to first child
          const firstChild = target.querySelector('[role="treeitem"]');
          if (firstChild) focusItem(firstChild);
        }
        break;

      case 'ArrowLeft':
        e.preventDefault();
        if (target.getAttribute('aria-expanded') === 'true') {
          target.setAttribute('aria-expanded', 'false');
        } else {
          // Move to parent treeitem
          const parentItem = target.parentElement.closest('[role="treeitem"]');
          if (parentItem) focusItem(parentItem);
        }
        break;

      case 'Home':
        e.preventDefault();
        focusItem(items[0]);
        break;

      case 'End':
        e.preventDefault();
        focusItem(items[items.length - 1]);
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        selectItem(target);
        // Toggle expand/collapse if the item has children
        if (target.hasAttribute('aria-expanded')) {
          const expanded = target.getAttribute('aria-expanded') === 'true';
          target.setAttribute('aria-expanded', String(!expanded));
        }
        break;
    }
  });

  // Click to expand/collapse and select
  treeEl.addEventListener('click', (e) => {
    const item = e.target.closest('[role="treeitem"]');
    if (!item) return;
    focusItem(item);
    selectItem(item);
    if (item.hasAttribute('aria-expanded')) {
      const expanded = item.getAttribute('aria-expanded') === 'true';
      item.setAttribute('aria-expanded', String(!expanded));
    }
  });
}

// Initialize
document.querySelectorAll('[role="tree"]').forEach(initTree);

// 03 · why these decisions

Why These Decisions

Why role="tree" and role="treeitem"?

There is no native HTML element for hierarchical tree widgets. The tree role tells assistive technology this is an interactive hierarchical widget, not a plain list. Screen readers switch to application-mode-like interaction, where arrow keys navigate between items instead of scrolling the page. Each treeitem is announced with its position in the set (e.g., "3 of 5") and nesting level.

Why roving tabindex instead of aria-activedescendant?

Roving tabindex moves actual DOM focus to each item, which means browser-native focus indicators, scroll-into-view behavior, and touch device compatibility all work automatically. With aria-activedescendant, focus stays on the container and the "active" item is only communicated via ARIA, which some assistive technologies handle inconsistently. Roving tabindex is more reliable and has better cross-browser support.

Why aria-expanded only on parent nodes?

Leaf nodes (files) have no children to expand or collapse. Adding aria-expanded to leaf nodes would confuse screen reader users because it implies there is hidden content that can be revealed. Only nodes with children receive aria-expanded, and the value toggles between "true" and "false" as the user expands and collapses them.

Why a single tab stop for the entire tree?

A tree with 50 or 100 nodes would be unusable if every node was in the tab order. The single-tab-stop pattern means Tab moves focus into the tree (landing on the last-focused item), arrow keys navigate within the tree, and Tab again moves past the tree to the next widget. This matches the WAI-ARIA TreeView pattern and is consistent with how native OS tree views work.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Up Arrow Moves focus to the previous visible tree item
Down Arrow Moves focus to the next visible tree item
Right Arrow On a closed node: opens the node. On an open node: moves focus to the first child. On a leaf node: does nothing.
Left Arrow On an open node: closes the node. On a closed or leaf node: moves focus to the parent node.
Home Moves focus to the first visible tree item
End Moves focus to the last visible tree item
Enter Selects the focused item and toggles expand/collapse on parent nodes
Space Selects the focused item and toggles expand/collapse on parent nodes
Arrow keys, not Tab Unlike accordions or tab lists, tree views use Up/Down arrow keys to move between items. Tab moves focus in and out of the entire tree widget. This is the standard interaction model defined in the WAI-ARIA TreeView pattern and matches native OS tree controls.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — The tree, treeitem, and group roles convey the hierarchical parent-child relationships programmatically. Screen readers announce nesting level and position within each level.
  • 2.1.1 Keyboard Level A — All tree operations are fully keyboard accessible: arrow keys for navigation, Enter/Space for selection, Right/Left for expand/collapse.
  • 2.4.3 Focus Order Level A — Focus moves through the tree in a logical top-to-bottom order, skipping collapsed (hidden) children. The single tab stop integrates cleanly with the rest of the page's focus order.
  • 4.1.2 Name, Role, Value Level A — Each tree item exposes its name (label text), role (treeitem), and value (aria-expanded state, aria-selected state) to assistive technology. The tree root has an accessible name via aria-label.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to a collapsed folder

  • NVDA: "components, collapsed, tree item, 1 of 2, level 2" — announces the label, expanded state, role, position, and nesting level
  • JAWS: "components, collapsed, 1 of 2, tree view, level 2" — similar announcement with position and depth context
  • VoiceOver: "components, collapsed, tree item, 1 of 2, level 2" — announces label, state, role, position in set, and level

When expanding a folder

  • NVDA: "expanded" — announces the new state after pressing Right Arrow or Enter
  • JAWS: "expanded" — confirms the state change
  • VoiceOver: "expanded, 3 items" — announces the state change and the number of child items revealed

When navigating to a leaf node (file)

  • NVDA: "Button.tsx, tree item, 1 of 3, level 3" — no expanded/collapsed state because it has no children
  • JAWS: "Button.tsx, 1 of 3, level 3" — position and depth only
  • VoiceOver: "Button.tsx, tree item, 1 of 3, level 3" — label, role, position, and level

When selecting an item

  • NVDA: "selected" — announces the selection state change
  • JAWS: "selected" — confirms the item is now selected
  • VoiceOver: "selected" — announces the new selection state

// 07 · common mistakes

Common Mistakes

Using nested <ul> elements without tree roles Plain nested lists communicate hierarchy visually but not to screen readers in a way that enables tree navigation. Without role="tree", role="treeitem", and role="group", assistive technology treats the structure as nested static lists. Users cannot use arrow key navigation and do not hear level or position announcements.
Putting every tree item in the tab order Setting tabindex="0" on every treeitem forces keyboard users to tab through every single node to get past the tree. A tree with 40 items means 40 tab presses. Use roving tabindex so only one item has tabindex="0" at a time, and use arrow keys for internal navigation.
Adding aria-expanded to leaf nodes Leaf nodes have no children, so aria-expanded is meaningless on them. Screen readers will announce "collapsed" or "expanded" on items that can never expand, confusing users. Only add aria-expanded to nodes that contain a nested role="group".
Missing aria-label or aria-labelledby on the tree root Without a label on the role="tree" element, screen readers announce "tree" with no context about what the tree contains. Always provide a descriptive label like "Project files" or "Site navigation" so users know what they are navigating.
Not managing focus when collapsing a parent If a child item is focused and the parent collapses, the focused item becomes hidden. Move focus to the parent item when collapsing to prevent focus from being lost in the DOM. Lost focus forces screen reader users to navigate back to the tree from scratch.
Forgetting role="group" on nested lists Without role="group" on the <ul> that wraps child items, screen readers may not correctly convey that those items are children of the parent node. The group role establishes the parent-child relationship and enables correct level announcements.