// 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.
// 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
-
utils
- format.ts
- helpers.ts
- index.ts
- App.tsx
-
components
- package.json
- tsconfig.json
// 02 · the code
The Code
<!-- 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>
/* 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;
}
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 |
// 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, andgrouproles 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-expandedstate,aria-selectedstate) to assistive technology. The tree root has an accessible name viaaria-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
<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.
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.
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".
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.
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.