
Building Accessible Hierarchical Tree Views with WAI-ARIA Tree Roles
Hierarchical tree structures are an essential UI pattern in modern web software, powering code repository file explorers, cloud storage navigation panels, nested configuration settings, and document outlines. Despite their visual simplicity, crafting an accessible tree view is fraught with pitfalls when developers rely solely on nested unordered lists. A generic <ul> and <li> structure fails to inform assistive technology users of dynamic parent-child relationships, folder expansion states, or hierarchical nesting levels. Furthermore, defaulting to standard browser tab stops forces keyboard users to navigate through hundreds of individual DOM nodes sequentially, rendering deep directories tedious and inaccessible.
The WAI-ARIA Tree pattern provides a standardized architecture specifically engineered for nested, expandable hierarchies. By establishing a root container with role="tree", individual branch and leaf nodes with role="treeitem", and nested sub-collections enclosed within role="group", screen readers accurately convey structural context such as "Level 2, item 3 of 5, collapsed." Combined with a roving tabindex strategy, the tree behaves as a single composite widget in the document tab order. Users navigate visible nodes efficiently using standard arrow key conventions—ArrowDown and ArrowUp to traverse visible items, ArrowRight to expand folders or descend to the first child, and ArrowLeft to collapse folders or ascend to the parent directory.
Example
<!-- Single tab-stop hierarchical tree view -->
<ul role="tree" aria-label="Project Files" class="tree-root">
<li role="treeitem" aria-expanded="false" aria-level="1" tabindex="0" class="tree-node is-folder">
<span class="node-label">src</span>
<ul role="group" hidden class="tree-subgroup">
<li role="treeitem" aria-level="2" tabindex="-1" class="tree-node is-file">
<span class="node-label">index.js</span>
</li>
<li role="treeitem" aria-level="2" tabindex="-1" class="tree-node is-file">
<span class="node-label">styles.css</span>
</li>
</ul>
</li>
<li role="treeitem" aria-level="1" tabindex="-1" class="tree-node is-file">
<span class="node-label">package.json</span>
</li>
</ul>
Implementation
/**
* WAI-ARIA Accessible Tree View Component
* Implements roving tabindex, hierarchical grouping, and keyboard standards.
*/
class AccessibleTreeView {
constructor(treeElement) {
this.tree = treeElement;
this.items = Array.from(this.tree.querySelectorAll('[role="treeitem"]'));
this.focusedItem = null;
this.init();
}
init() {
this.tree.addEventListener('keydown', (e) => this.handleKeyDown(e));
this.tree.addEventListener('click', (e) => this.handleClick(e));
// Initialize roving tabindex: first item is tabbable, rest are -1
this.items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
// Initialize aria-selected if not present
if (!item.hasAttribute('aria-selected')) {
item.setAttribute('aria-selected', 'false');
}
// Check for nested subgroup to establish folder role
const subGroup = item.querySelector(':scope > [role="group"]');
if (subGroup) {
if (!item.hasAttribute('aria-expanded')) {
item.setAttribute('aria-expanded', subGroup.hidden ? 'false' : 'true');
}
}
});
this.focusedItem = this.items[0];
}
/**
* Returns only items currently visible in the DOM (not collapsed inside hidden groups)
*/
getVisibleItems() {
return this.items.filter((item) => {
let parent = item.parentElement;
while (parent && parent !== this.tree) {
if (parent.getAttribute('role') === 'group' && parent.hidden) {
return false;
}
parent = parent.parentElement;
}
return true;
});
}
/**
* Updates physical focus and roving tabindex
*/
focusItem(item) {
if (!item) return;
if (this.focusedItem) {
this.focusedItem.setAttribute('tabindex', '-1');
}
this.focusedItem = item;
item.setAttribute('tabindex', '0');
item.focus();
}
/**
* Expands a branch node and reveals its subgroup
*/
expandNode(item) {
const subGroup = item.querySelector(':scope > [role="group"]');
if (subGroup && item.getAttribute('aria-expanded') === 'false') {
item.setAttribute('aria-expanded', 'true');
subGroup.hidden = false;
}
}
/**
* Collapses a branch node and hides its subgroup
*/
collapseNode(item) {
const subGroup = item.querySelector(':scope > [role="group"]');
if (subGroup && item.getAttribute('aria-expanded') === 'true') {
item.setAttribute('aria-expanded', 'false');
subGroup.hidden = true;
}
}
/**
* Keyboard Navigation Protocol
*/
handleKeyDown(event) {
const target = event.target.closest('[role="treeitem"]');
if (!target) return;
const visibleItems = this.getVisibleItems();
const currentIndex = visibleItems.indexOf(target);
const isBranch = target.hasAttribute('aria-expanded');
const isExpanded = target.getAttribute('aria-expanded') === 'true';
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (currentIndex < visibleItems.length - 1) {
this.focusItem(visibleItems[currentIndex + 1]);
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
this.focusItem(visibleItems[currentIndex - 1]);
}
break;
case 'ArrowRight':
event.preventDefault();
if (isBranch) {
if (!isExpanded) {
this.expandNode(target);
} else {
// Move focus to first child item
const firstChild = target.querySelector('[role="group"] > [role="treeitem"]');
if (firstChild) this.focusItem(firstChild);
}
}
break;
case 'ArrowLeft':
event.preventDefault();
if (isBranch && isExpanded) {
this.collapseNode(target);
} else {
// Ascend to parent treeitem node if nested inside a group
const parentGroup = target.parentElement.closest('[role="group"]');
if (parentGroup) {
const parentItem = parentGroup.closest('[role="treeitem"]');
if (parentItem) this.focusItem(parentItem);
}
}
break;
case 'Home':
event.preventDefault();
if (visibleItems.length > 0) {
this.focusItem(visibleItems[0]);
}
break;
case 'End':
event.preventDefault();
if (visibleItems.length > 0) {
this.focusItem(visibleItems[visibleItems.length - 1]);
}
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectItem(target);
if (isBranch) {
if (isExpanded) {
this.collapseNode(target);
} else {
this.expandNode(target);
}
}
break;
}
}
handleClick(event) {
const target = event.target.closest('[role="treeitem"]');
if (!target) return;
this.focusItem(target);
this.selectItem(target);
if (target.hasAttribute('aria-expanded')) {
const isExpanded = target.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
this.collapseNode(target);
} else {
this.expandNode(target);
}
}
}
selectItem(item) {
this.items.forEach((i) => i.setAttribute('aria-selected', 'false'));
item.setAttribute('aria-selected', 'true');
}
}
// Instantiate tree view on DOM load
const treeElement = document.querySelector('[role="tree"]');
if (treeElement) {
new AccessibleTreeView(treeElement);
}
Main Point: The WAI-ARIA Tree pattern combines explicit role semantics with roving tabindex keyboard controls to turn complex nested hierarchies into accessible, easily traversable navigational components.
Credits
Photo by Luca Bravo on Unsplash