Data Display

Tree View

Available

Displays a hierarchical structure. Supports expand/collapse and navigation similar to a folder tree.

What Is a Tree View?

A tree view is a UI that displayshierarchical structure with collapsible branches, much like folders and files. It's commonly used in file explorers, sidebar menus, org charts, and category lists.

The key aspects are "parent-child nesting (hierarchy)" and "expandable/collapsible branches." Visually, indentation and triangle markers convey structure, but for users who can't see the screen, you need to communicate"which level they're on, which item out of how many, and whether it's expanded or collapsed". That's the heart of accessibility here.

Why Does Accessibility Matter?

Building a tree from visual styling alone leaves these people behind:

The solution is to use role="tree" on the container, role="treeitem" on each row,role="group" on child collections, and aria-expanded on expandable branches. Additionally, the entire tree should be a single Tab stop (roving tabindex), with arrow keys handling movement within.

Live Demo (Recommended Implementation)

The file tree below follows the APG specification. Try navigating it without using the mouse, using only the keyboard.

Accessible Tree View
  • 📁 Documents
    • 📁 Projects
      • 📄 plan.md
      • 📄 minutes.md
    • 📄 resume.pdf

Try it: Tab once to enter the tree → ↓ ↑ to move between items → → to expand a branch / move to child, ← to collapse / move to parent → Home / End to jump to first / last.

Tip

Turn on a screen reader (on macOS, press +F5 to toggle VoiceOver) and focus an item. You'll hear something like "Documents, expanded, 1 of 2, level 1" —name, expand/collapse state, position, and hierarchy level. This is the effect of role="tree" and aria-expanded.

Keyboard Interaction

KeyActionPriority
TabEnter / leave the tree (only one tab stop for the entire tree)Required
/ Move focus to the next / previous visible itemRequired
Expand a closed branch. Move to first child if open. No action on a leaf nodeRequired
Collapse an open branch. Otherwise move to the parent itemRequired
Home / EndMove to the first / last visible itemRecommended

Note

A tree follows the "one Tab stop for the entire tree" principle. Only one item has tabindex="0" (making it the focusable target), while all others have tabindex="-1". Arrow keys switch which item is focusable. This mechanism is called roving tabindex.

Required WAI-ARIA Roles & Properties

TargetAttribute / RoleMeaning
Containerrole="tree" + aria-labelThe entire hierarchical list. Also provide a label (name).
Each rowrole="treeitem"A single item in the tree (folder/file).
Expandable brancharia-expanded="true | false"Whether children are expanded. Must be updated on toggle. Do not apply to leaf nodes.
Group of childrenrole="group"A container for the child treeitems of a parent item.
Each row (optional)aria-level / aria-setsize / aria-posinsetDepth level, total count of siblings, and position within siblings. Inferred from nesting but can be explicit.
Each rowtabindex="0 | -1"Only the focused item gets 0; all others get -1 (roving tabindex).

Recommended Pattern (Good)

Good / Recommended

Use tree / treeitem / group to represent hierarchy, and synchronize aria-expanded and roving tabindex with JavaScript.

Markup:

<ul role="tree" aria-label="Files">
  <li role="treeitem" aria-expanded="true" tabindex="0">
    <span class="tree-label">📁 Documents</span>
    <ul role="group">
      <li role="treeitem" aria-expanded="false" tabindex="-1">
        <span class="tree-label">📁 Projects</span>
        <ul role="group">
          <li role="treeitem" tabindex="-1">
            <span class="tree-label">📄 plan.md</span>
          </li>
          <li role="treeitem" tabindex="-1">
            <span class="tree-label">📄 minutes.md</span>
          </li>
        </ul>
      </li>
      <li role="treeitem" tabindex="-1">
        <span class="tree-label">📄 resume.pdf</span>
      </li>
    </ul>
  </li>
</ul>

Keyboard interaction and state synchronization script:

const tree = document.querySelector('[role="tree"]');
// Collect only visible treeitems (those whose ancestors are all expanded)
function visibleItems() {
  return Array.from(tree.querySelectorAll('[role="treeitem"]'))
    .filter((el) => !el.closest('[role="group"][hidden]'));
}
function isParent(el) {
  return el.hasAttribute('aria-expanded');
}
function moveTo(el) {
  // Roving tabindex: only the focused item gets 0, others get -1
  visibleItems().forEach((i) => i.setAttribute('tabindex', '-1'));
  el.setAttribute('tabindex', '0');
  el.focus();
}
function setExpanded(el, open) {
  el.setAttribute('aria-expanded', String(open));
  // Toggle the direct child group (leave grandchildren as-is)
  const group = el.querySelector(':scope > [role="group"]');
  if (group) group.hidden = !open;
}

tree.addEventListener('keydown', (e) => {
  const cur = e.target.closest('[role="treeitem"]');
  if (!cur) return;
  const items = visibleItems();
  const i = items.indexOf(cur);
  switch (e.key) {
    case 'ArrowDown':
      if (i < items.length - 1) moveTo(items[i + 1]);
      break;
    case 'ArrowUp':
      if (i > 0) moveTo(items[i - 1]);
      break;
    case 'ArrowRight':
      if (isParent(cur) && cur.getAttribute('aria-expanded') === 'false') {
        setExpanded(cur, true); // Closed parent → expand
      } else if (isParent(cur)) {
        const child = cur.querySelector(':scope > [role="group"] [role="treeitem"]');
        if (child) moveTo(child); // Open parent → move to first child
      }
      break;
    case 'ArrowLeft':
      if (isParent(cur) && cur.getAttribute('aria-expanded') === 'true') {
        setExpanded(cur, false); // Open parent → collapse
      } else {
        const parentGroup = cur.parentElement.closest('[role="group"]');
        const parent = parentGroup &&
          parentGroup.closest('[role="treeitem"]');
        if (parent) moveTo(parent); // Otherwise → move to parent
      }
      break;
    case 'Home':
      moveTo(items[0]);
      break;
    case 'End':
      moveTo(items[items.length - 1]);
      break;
    default:
      return;
  }
  e.preventDefault();
});

Note

Arrow key "movement" targets only visible items (those whose ancestors are all expanded). Items inside a collapsed branch are within a hidden group, so they must be excluded from the navigation candidates.

Anti-Pattern (Bad)

Below is a "visual-only tree" built with <ul>/<li> and onclick.It works with a mouse but is completely inoperable via keyboard.Compare the difference with the demo above.

Broken tree built with nested lists
  • 📁 Documents
    • 📁 Projects
    • 📄 resume.pdf

Try it: Pressing Tab doesn't focus any item. A screen reader announces it as 'just a list' — hierarchy, expand/collapse state, and position are completely missing.

<!-- ❌ Anti-pattern -->
<!-- Just a nested list. No role or aria-expanded -->
<ul class="bad-tree">
  <li>
    <span onclick="toggle(this)">📁 Documents</span>
    <ul>
      <li>
        <span onclick="toggle(this)">📁 Projects</span>
        <ul style="display:none">
          <li><span>📄 plan.md</span></li>
        </ul>
      </li>
      <li><span>📄 resume.pdf</span></li>
    </ul>
  </li>
</ul>

Bad / Avoid

Problems with this implementation:

  • Not keyboard operablespan/li elements can't receive focus, and there's no arrow key navigation.
  • Not recognized as a tree — Without role="tree", screen readers announce it as "just a list."
  • Expand/collapse state not conveyed — Without aria-expanded, users can't tell if a branch is open or closed.
  • Hierarchy and position not conveyed — The current level and position within the set are not announced.

Tip

Using nested <ul>/<li> as a base is fine. Add role="treeitem" to the li elements, role="tree" to the outer container,role="group" to child ul elements, then add aria-expanded, roving tabindex, and arrow key handling — and you get an accessible tree with the same visual appearance.

Implementation Checklist


Source (English):Tree View Pattern — W3C APG(opens in a new tab)