Accessible Tree View Guide
A tree view lets a user browse and select nodes in a hierarchy — a file explorer, a folder picker, a settings tree. This guide covers the role="tree" / treeitem / group structure, the roving-tabindex focus model, the context-sensitive Right and Left arrows that expand, collapse, and move between parent and child, aria-level, aria-setsize, aria-posinset, selection, and React — with copy-ready code mapped to WCAG 2.2, and the one question to ask before you build one at all.
The Widget Defined by Its Arrow Keys
A tree view is the control you reach for when the data is a hierarchy the user needs to walk — folders inside folders, an org chart, a category taxonomy, a nested set of settings. On screen it is a column of indented rows with little twisties that open and close branches. What makes it a tree to assistive technology, and not just an indented list, is a specific bundle of semantics and a specific keyboard model: one Tab stop, arrow keys to move, and — the part that defines the pattern — Right and Left arrows that expand and collapse branches and step between a parent and its children.
The tree has an ARIA pattern of its own — the WAI-ARIA Authoring Practices Tree View pattern — built from role="tree", role="treeitem", and role="group", with aria-expanded on the branches, aria-selected for what is chosen, and aria-level, aria-setsize and aria-posinset to say where in the hierarchy each node sits. Unlike a slider or a switch, there is no native HTML element that gives you a tree — you build the whole thing, which is exactly why so many trees ship broken.
Before anything else: most “trees” should not be trees. If your nodes are links that navigate to pages, a nested list of links with disclosure buttons is more robust and needs a fraction of the code. Reserve role="tree" for when the user operates on the hierarchy — expanding, selecting, browsing within one view. Section 1 is the decision.
This guide walks that decision first, then the anatomy, the DOM structure, the full keyboard model, the roving-tabindex focus engine, the Right/Left arrow logic in depth, selection (single and multi), the level and position properties, a React approach, and how to test the result — because with no native element to lean on, testing by hand is the only way to know a tree actually works.
The WCAG 2.2 Criteria a Tree View Must Satisfy
| Criterion | Level | What the tree must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | The hierarchy, each node's level and position, and the expanded and selected states are exposed programmatically, not implied by indentation alone. |
| 2.1.1 Keyboard | A | Every action — move, expand, collapse, select, activate — works from the keyboard, including the Right and Left arrows most trees forget. |
| 2.1.2 No Keyboard Trap | A | Tab moves focus into the tree and Tab moves it out again — the arrows navigate inside, they do not trap. |
| 2.4.3 Focus Order | A | Roving tabindex keeps the tree a single Tab stop and focus never lands on a hidden node — collapsing a branch moves focus to its parent. |
| 1.4.1 Use of Color | A | Which node is selected is shown by more than colour — aria-selected plus a check icon or bold label, not a background tint alone. |
| 2.4.7 Focus Visible | AA | The focused node shows a clearly visible indicator that is distinct from the selected-node styling. |
| 1.4.11 Non-text Contrast | AA | The expand/collapse twisty, the focus ring, and the selection indicator each reach at least 3:1 against what they sit on. |
| 2.5.8 Target Size (Minimum) | AA | The clickable row or the expand twisty is at least 24 by 24 CSS pixels so it can be operated by touch and imprecise pointers. |
| 4.1.2 Name, Role, Value | A | Each node exposes the treeitem role, its own name, and its state — expanded/collapsed for parents, selected for chosen nodes. |
The criterion trees fail most often is 2.1.1 Keyboard, because the Right and Left arrow behaviour that expands, collapses, and steps between parent and child is genuinely fiddly and easy to skip — leaving branches that a mouse can open but a keyboard cannot. The second is 2.4.3 Focus Order, when a collapsing branch strands focus on a node that has just been hidden.
1. Do You Even Need a Tree?
A tree is one of the heaviest patterns in the ARIA toolkit — there is no native element, so you own the roles, the roving focus, and the whole keyboard model. Reach for it only when the user genuinely operates on a hierarchy inside a single view. For many things that look like a tree, a simpler pattern is more robust and far less code. Match the control to the job before you build.
Tree view
The user browses and selects nodes in a hierarchy within one view — a file explorer, a folder picker, an org chart, a settings tree. role="tree". This is what the guide covers.
Nested nav links
The nodes are links that navigate to pages — docs sidebars, category menus. A nested <ul> of <a> in a <nav>, with disclosure buttons for collapsible sections.
Disclosure / accordion
A flat set of show/hide sections with no real hierarchy — FAQs, panels. The accordion & disclosure pattern with aria-expanded, no roving tabindex needed.
The single clearest tell: do the nodes take you somewhere, or do you act on them here? Links that navigate want a nested list of links — that pattern is battle-tested, works without JavaScript, and matches what screen reader users expect. A tree earns its cost only when expanding and selecting nodes is the interaction: a two-pane file browser, a component inspector, a taxonomy editor. When in doubt, start with the list of links and upgrade only if the interaction truly demands it.
2. Anatomy: Roles, States, and Properties
A tree carries more structural information than almost any other widget, because every node has to broadcast not just what it is but where it sits: its depth, its position among siblings, whether it is open, and whether it is selected. Here is the full set and what each part is for.
| Element / concept | Attribute | Purpose |
|---|---|---|
| The tree container | role="tree" + aria-label / aria-labelledby | The wrapper (usually a <ul>). A single Tab stop that needs an accessible name. Add aria-multiselectable="true" when more than one node can be selected at once. |
| Each node | role="treeitem" | Every openable or selectable node (usually an <li>). Carries the node's state, level, and position. |
| A parent's children | role="group" | Wraps the set of child nodes of an expandable node (the nested <ul>) so the hierarchy and levels are exposed to assistive technology. |
| Open / closed state | aria-expanded | On parent nodes only. "false" is a closed branch, "true" is open. Never place it on a leaf node — it announces something that cannot open. |
| Selection state | aria-selected | Which node(s) are selected. One "true" in a single-select tree; an explicit "true"/"false" on every node in a multi-select tree. |
| Depth | aria-level | The 1-based depth of the node. The root row is level 1, its children level 2, and so on — the audible version of indentation. |
| Position in level | aria-setsize / aria-posinset | How many siblings share this branch and which one this is ("3 of 7"). Essential once nodes are virtualised or lazy-loaded. |
| Roving focus | tabindex="0" / tabindex="-1" | Exactly one node has tabindex="0" (the current node); every other node has tabindex="-1". The arrow keys move the 0. |
A subtlety worth calling out: a parent node's accessible name should be just its own label, not the text of everything beneath it. Keep each node's label in a single element (a <span>in the examples below) so it reads as “Documents”, and if your assistive technology reads the descendants too, give the parent an explicit aria-label or aria-labelledby pointing at that label. For how each role and property surfaces to assistive technology, see the ARIA roles & attributes reference.
3. The HTML Structure
Build the tree from a nested list. The outer <ul> is the role="tree", each <li> is a role="treeitem", and the children of an expandable node live in a nested <ul role="group">. Parent nodes get aria-expanded; leaf nodes do not. Exactly one node — here the first — starts at tabindex="0".
<ul role="tree" aria-label="File system">
<!-- A parent node: has aria-expanded and a role="group" of children. -->
<li
role="treeitem"
aria-expanded="false"
aria-level="1"
aria-setsize="2"
aria-posinset="1"
tabindex="0"
>
<span class="tree__label">Documents</span>
<ul role="group">
<li role="treeitem" aria-level="2" aria-setsize="2" aria-posinset="1" tabindex="-1">
<span class="tree__label">Resume.pdf</span>
</li>
<li role="treeitem" aria-level="2" aria-setsize="2" aria-posinset="2" tabindex="-1">
<span class="tree__label">Cover-letter.pdf</span>
</li>
</ul>
</li>
<!-- A leaf node: NO aria-expanded, no group. -->
<li role="treeitem" aria-level="1" aria-setsize="2" aria-posinset="2" tabindex="-1">
<span class="tree__label">Readme.txt</span>
</li>
</ul>The role="group" on the nested list is not optional. It is what tells assistive technology that these <li>s are the children of the node above, rather than more siblings. Drop it and the tree flattens: levels stop lining up and the parent/child relationship disappears. Note also that aria-expanded lives on the parent treeitem, not on the group.
When a branch is collapsed, hide its role="group" with hidden or display:noneso its nodes leave the accessibility tree entirely — a collapsed branch's children must not be reachable by the arrow keys or a screen reader's virtual cursor. Set aria-expanded="false" and hide the group together, as one operation.
4. The Keyboard Model
This is the contract that turns a nested list into a tree, and there is no native element to implement it for you — every key here is yours to wire up. A tree that responds only to Up and Down is not a tree; it is an inaccessible list with extra roles.
| Key | Expected behavior |
|---|---|
| Down Arrow | Moves focus to the next visible node, reading top to bottom through the tree as it is currently expanded — it does not open or close anything. |
| Up Arrow | Moves focus to the previous visible node. |
| Right Arrow | On a closed parent: opens it, focus stays put. On an already-open parent: moves focus to its first child. On a leaf node: does nothing. |
| Left Arrow | On an open parent: closes it. On a closed node or a leaf: moves focus to the parent node. On a top-level closed node: does nothing. |
| Home | Moves focus to the first node in the tree. |
| End | Moves focus to the last node that is currently visible. |
| Enter | Activates the focused node — performs its default action, such as opening a file or choosing the item. May also toggle selection depending on the design. |
| Space | In a selection tree, toggles whether the focused node is selected (aria-selected). |
| Type a character | Type-ahead: moves focus to the next visible node whose label starts with the typed character(s), wrapping to the top if needed. |
| * (asterisk) | Optional: expands every closed sibling at the same level as the focused node. |
| Tab / Shift + Tab | Moves focus into and out of the whole tree. The tree is a single Tab stop; the arrows do the navigating inside it. |
Note that “visible node” means visible in the tree as currently expanded — Down Arrow from an open folder goes to its first child, but from a closed folder it skips over the hidden children to the next sibling. For the wider contract every custom widget owes, and the roving-tabindex technique the next section builds on, see the keyboard accessibility guide and the focus management guide.
5. Roving Tabindex: One Tab Stop, Not Four Hundred
A tree is a composite widget: it should be a single stop in the Tab order, and once focus is inside, the arrow keys move between nodes. The technique that achieves this is roving tabindex. Exactly one node carries tabindex="0" and is in the Tab sequence; every other node carries tabindex="-1", which keeps it focusable by script but out of the Tab order. When the user arrows to a new node, you move the 0.
const tree = document.querySelector('[role="tree"]')
// Move focus from the current node to a target node.
function focusNode(target) {
if (!target) return
// Take the current node out of the Tab order...
tree.querySelector('[tabindex="0"]')?.setAttribute("tabindex", "-1")
// ...and put the target in it, then move real DOM focus there.
target.setAttribute("tabindex", "0")
target.focus()
}The number-one tree bug is giving every node tabindex="0". It looks like it works with a mouse, but now a keyboard user has to Tab through every file to get past the widget, and the arrow-key model never runs. A tree with four hundred nodes becomes four hundred Tab stops. One 0, the rest -1 — always.
There is a second valid model, aria-activedescendant: DOM focus stays on the role="tree" container, and an aria-activedescendant attribute on it points at the id of the active node, which you move as the user arrows. It avoids shuffling tabindex and can be simpler with virtualised lists, but it puts the burden of a visible “active” style entirely on your CSS. Roving tabindex is the more common choice and the one the rest of this guide uses. See focus management for both models side by side.
6. The Right and Left Arrows — the Logic That Defines a Tree
This is the behaviour that makes a tree a tree, and it is context-sensitive: the same key does different things depending on whether the focused node is a leaf, a closed parent, or an open parent. Get this right and a keyboard user can walk the entire hierarchy with two keys; get it wrong — or leave it out — and whole branches become mouse-only.
Right Arrow
- Closed parent: open it; focus stays.
- Open parent: move focus to its first child.
- Leaf: do nothing.
Left Arrow
- Open parent: close it; focus stays.
- Closed node or leaf: move focus to the parent node.
- Top-level closed node: do nothing.
tree.addEventListener("keydown", (e) => {
const node = e.target.closest('[role="treeitem"]')
if (!node) return
const isParent = node.hasAttribute("aria-expanded")
const isOpen = node.getAttribute("aria-expanded") === "true"
switch (e.key) {
case "ArrowDown": focusNode(nextVisibleNode(node)); break
case "ArrowUp": focusNode(previousVisibleNode(node)); break
case "ArrowRight":
if (isParent && !isOpen) setExpanded(node, true) // open it
else if (isParent && isOpen) focusNode(firstChild(node)) // step in
// leaf: do nothing
break
case "ArrowLeft":
if (isParent && isOpen) setExpanded(node, false) // close it
else focusNode(parentNode(node)) // step out
break
case "Home": focusNode(firstNode()); break
case "End": focusNode(lastVisibleNode()); break
case "Enter": activate(node); break
case " ": toggleSelected(node); break // selection trees
default: return // let other keys through
}
e.preventDefault() // <- stop the arrows scrolling the page
})
// Expanding/collapsing is one operation: attribute + visibility together.
function setExpanded(node, open) {
node.setAttribute("aria-expanded", String(open))
node.querySelector(':scope > [role="group"]')?.toggleAttribute("hidden", !open)
}Two details carry a lot of weight. First, the guard node.hasAttribute("aria-expanded") is how the code knows a node is a parent at all — which is exactly why aria-expanded must be present (even as "false") on every parent and absent on every leaf. Second, the closing e.preventDefault() stops the arrow keys from scrolling the page instead of moving through the tree, the same trap custom sliders and menus fall into. And when you collapse a branch whose child holds focus, move focus to the parent first — never leave it on a node you are about to hide, which would break 2.4.3 Focus Order.
7. Selection: Single vs Multi-Select
A tree is a selection widget, so the chosen state belongs on aria-selected — not aria-checked, which is for checkboxes and switches. There are two models, and the difference is one attribute on the container plus how many nodes can be true at once.
<!-- Single-select: one node is aria-selected="true", Enter selects. -->
<ul role="tree" aria-label="Categories">
<li role="treeitem" aria-selected="true" aria-level="1" tabindex="0">Design</li>
<li role="treeitem" aria-selected="false" aria-level="1" tabindex="-1">Engineering</li>
</ul>
<!-- Multi-select: aria-multiselectable on the tree, every node
carries an explicit aria-selected, Space toggles the focused one. -->
<ul role="tree" aria-label="Files to export" aria-multiselectable="true">
<li role="treeitem" aria-selected="true" aria-level="1" tabindex="0">chart.png</li>
<li role="treeitem" aria-selected="false" aria-level="1" tabindex="-1">data.csv</li>
<li role="treeitem" aria-selected="true" aria-level="1" tabindex="-1">notes.md</li>
</ul>In a single-select tree, one node is aria-selected="true" and selecting another moves that true; Enter on a node selects it and performs its action. In a multi-select tree, set aria-multiselectable="true" on the role="tree" and give every node an explicit aria-selected of true or false so a screen reader can announce the state of each. Space toggles the focused node, and the standard extensions — Shift+Down/Up to extend a range, Ctrl+A to select all — round it out.
Never show selection by colour alone. A tinted row means nothing to a screen reader without aria-selected, and nothing to a colour-blind user without a second cue. Pair the state with a check icon, a bold label, or a leading marker so it survives both 1.4.1 Use of Color and 4.1.2.
8. Telling the User Where They Are: Level and Position
A sighted user reads depth from indentation and position from the rows above and below. A screen reader user gets that same orientation from three properties, and without them a deep tree becomes a disorienting flat list of names with no sense of how far in or how far down you are.
aria-level— the 1-based depth. Root nodes are level 1, their children level 2, and so on. The user hears “level 3” and knows exactly how deep they have gone.aria-setsize— how many siblings share this branch, so the user hears “of 7”.aria-posinset— which sibling this is, so the user hears “3 of 7”.
When the whole tree is in the DOM and correctly nested with role="group", some browsers can compute level and position for you — but setting all three explicitly is the reliable choice, and it becomes mandatory the moment you virtualise or lazy-load nodes: once the DOM no longer contains every sibling, the browser has nothing to count, and only your aria-setsize and aria-posinsetcan tell the user “3 of 7,000”. Treat them as part of building a tree, not as polish.
9. Tree Views in React
In React you render the nodes from data and keep the expanded and selected sets in state, but the accessibility contract does not change: the roles, aria-expanded, aria-selected, the level and position properties, the roving tabindex, and the full keyboard handler are all still yours to get right. A recursive component maps cleanly onto the recursive structure.
function TreeNode({ node, level, posInSet, setSize, state }) {
const isParent = node.children?.length > 0
const isOpen = state.expanded.has(node.id)
const isCurrent = state.tabbable === node.id
return (
<li
role="treeitem"
aria-expanded={isParent ? isOpen : undefined} // parents only
aria-selected={state.selected === node.id}
aria-level={level}
aria-posinset={posInSet}
aria-setsize={setSize}
tabIndex={isCurrent ? 0 : -1} // roving tabindex
>
<span className="tree__label">{node.label}</span>
{isParent && isOpen && (
<ul role="group">
{node.children.map((child, i) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
posInSet={i + 1}
setSize={node.children.length}
state={state}
/>
))}
</ul>
)}
</li>
)
}Because this is so much surface area, the honest advice for production is to lean on a well-tested headless implementation: React Aria's useTree / Tree, or a mature component library's tree, implements the roving focus, the Right/Left logic, type-ahead, and selection with the edge cases already handled. The same principles carry to other frameworks — see the React, Vue, and Angular guides. Whatever you ship, verify it against the workflow below rather than trusting the README.
How to Test an Accessible Tree View
Automated tools catch a missing role or an aria-expanded on a leaf, but almost everything that decides whether a tree is usable — the roving focus, the Right/Left logic, the announcements — is a hands-on check that takes a few minutes.
- Tab exactly once. Focus should land on a single node, and one more Tab should leave the tree entirely. If Tab steps node by node, you have the every-node-
tabindex=0bug. - Walk it with the arrows. Down and Up move between visible nodes; Right opens a branch and then steps into it; Left collapses and then steps out to the parent; Home and End jump to the ends. Type a letter and confirm focus jumps to a matching node. The page must not scroll while you do it.
- Listen with a screen reader. Each node should announce its label, “tree item”, its level, its position (“3 of 7”), and — for parents — expanded or collapsed, and — in a selection tree — selected or not. The screen reader testing guide has the commands for NVDA and VoiceOver.
- Collapse a branch from inside it. Focus a child, then collapse its parent (Left on the parent, or a collapse-all). Focus must jump to the parent, never vanish to the top of the page.
- Measure the visuals. The twisty icon, the focus ring, and the selection indicator each reach 3:1 (1.4.11); the clickable row or twisty is at least 24 × 24 px (2.5.8); selection is not colour alone (1.4.1).
Layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor to catch a missing name, role, or state before it ships.
Common Tree View Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| A nested <ul>/<li> marked role="tree" where every node has tabindex="0". | Every node becomes a Tab stop, so a keyboard user must Tab through the whole tree and the arrow-key model never engages — a 400-file tree is 400 Tab stops (2.1.1, 2.4.3). | Use roving tabindex: one node at tabindex="0", every other at tabindex="-1", and the arrow keys move focus. |
| role="tree" used for the site's primary navigation menu of links. | A tree implies hierarchical data the user operates on, and it strips the plain-link semantics screen reader users expect from navigation, forcing an arrow-key model on simple links (4.1.2 misuse). | Use a nested <ul> of <a> links inside a <nav>, with aria-expanded disclosure buttons for the collapsible sections. See the menu guide. |
| aria-expanded="false" on a leaf node that has no children. | The screen reader announces the node as "collapsed", so the user tries to open something that can never open (4.1.2). | Put aria-expanded on parent nodes only. Leaf nodes carry no aria-expanded at all. |
| Only Up and Down arrows are wired; Right and Left do nothing. | Expanding and collapsing branches is unreachable by keyboard, so a keyboard-only user cannot open the tree's contents (2.1.1). | Implement the Right/Left logic — open-then-step-in, and collapse-then-step-out — as in the arrow-key section. |
| Child nodes sit in a plain <ul> with no role="group". | The hierarchy flattens: levels no longer line up with a real group, and the parent/child relationship is lost to assistive technology (1.3.1, 4.1.2). | Wrap every set of children in <ul role="group">, nested inside the parent treeitem. |
| Collapsing a parent while focus is on one of its now-hidden children leaves focus on a hidden element. | Focus lands on something display:none or is lost to <body>, dropping the user to the top of the page mid-task (2.4.3, 2.1.1). | On collapse, move focus to the parent node being collapsed before its children are hidden. |
| The selected node is shown only by a background colour, with no aria-selected. | A screen reader user never learns which node is selected, and colour alone fails colour-blind users (1.4.1, 4.1.2). | Set aria-selected on the node and pair the colour with a non-colour cue such as a check icon or bold label. |
Accessible Tree View Checklist
- Right control. The user operates on a hierarchy in one view. If the nodes are links that navigate, use a nested list of links instead.
- Structure.
role="tree"on the container,role="treeitem"on every node,role="group"on every nested child list. - State.
aria-expandedon parents only (never on leaves);aria-selectedfor the chosen node(s) (4.1.2). - Position.
aria-level,aria-setsize, andaria-posinseton every node — required once you virtualise. - One Tab stop. Roving tabindex: exactly one node at
tabindex="0", the rest at-1(2.4.3). - Full keyboard. Up/Down, the Right/Left expand-collapse-and-move logic, Home, End, type-ahead, Enter, and Space all work, with no page scroll (2.1.1).
- Focus never lost. Collapsing a branch moves focus to its parent, never onto a hidden node.
- Visible and reachable. Twisty, focus ring, and selection each reach 3:1 (1.4.11); the target is at least 24 × 24 px (2.5.8); selection is not colour alone.
Work through the full WCAG 2.2 checklist to see the tree in the context of every other requirement.
Check Your Tree View on a Live Page
Scan any page with our free axe-core-powered auditor to catch a tree with a missing role, an aria-expanded on a leaf, or nodes with no accessible name — then run the Tab, arrow-key, and collapse passes above for the failures no scanner can see.
Frequently Asked Questions
What is the difference between a tree view and a navigation menu?▾
A tree view (role="tree") is for browsing and selecting items in a hierarchy — a file explorer, a folder structure, a category picker, a nested comment thread you can collapse. Navigation is different: a set of links that take you to pages. The two look alike because both can be nested and collapsible, but they carry different semantics and keyboard expectations. A tree is a single Tab stop that you drive with the arrow keys, and its nodes are announced as "tree item, level 2, 3 of 5, expanded". Navigation is a list of links you Tab through, announced as ordinary links. If your nodes are links to other pages, you almost certainly want a nested list of links inside a nav landmark — with aria-expanded disclosure buttons for the collapsible sections — not a tree. Reserve role="tree" for when the user is operating on the hierarchy itself: expanding branches, selecting nodes, moving items around within one view.
When should I use role="tree" instead of a nested list of links?▾
Use a tree only when three things are true: the content is genuinely hierarchical, the user browses and selects nodes within a single view rather than navigating away, and you are prepared to implement the full keyboard model — roving tabindex, up and down to move, right and left to expand and collapse, Home, End, type-ahead, and Enter or Space to act. That is a lot of behaviour to own, and if you get it half-right you produce something worse than a plain list. A nested <ul> of <a> links, with a disclosure button (aria-expanded) toggling each collapsible branch, is more robust, needs far less JavaScript, and is what screen reader users expect for anything that behaves like navigation. Reach for the tree when the widget is a file browser, an org chart, a settings tree, or a data explorer where selecting and expanding nodes is the whole point.
What keyboard interactions does an accessible tree view need?▾
The tree is one Tab stop. Once focus is inside, Down Arrow and Up Arrow move to the next and previous visible node. Right Arrow opens a closed parent (without moving focus), and on an already-open parent it moves focus to the first child; on a leaf it does nothing. Left Arrow closes an open parent, and on a closed node or a leaf it moves focus to the parent node. Home jumps to the first node and End to the last visible node. Enter activates the focused node — opens the file, follows the item. In a selection tree, Space toggles whether the focused node is selected. Typing a character (type-ahead) moves focus to the next node whose label starts with that character. Optionally, the asterisk key expands every sibling at the current level. Every one of these has to work with no pointer, or the tree fails 2.1.1 Keyboard.
What do the Right and Left arrow keys do in a tree?▾
The Right and Left arrows are what make a tree a tree, and their behaviour is context-sensitive — the same key does different things depending on the focused node's state. Right Arrow on a closed parent expands it and leaves focus where it is; press Right again and, now that the node is open, focus moves to its first child. Right Arrow on a leaf node (one with no children) does nothing. Left Arrow on an open parent collapses it. Left Arrow on a node that is already closed, or on a leaf, moves focus up to its parent node. Left Arrow on a top-level closed node does nothing. This "expand, then step in" and "collapse, then step out" logic lets a keyboard user walk the whole hierarchy with two keys, and it is the single most common thing custom trees get wrong — many wire only Up and Down, leaving expand and collapse unreachable without a mouse.
What is roving tabindex and why does a tree need it?▾
Roving tabindex is a focus-management technique where a composite widget is a single Tab stop, but internally focus roves between its items with the arrow keys. Exactly one node in the tree has tabindex="0" — the current node — and every other node has tabindex="-1", which keeps it focusable by script but out of the Tab sequence. When the user presses an arrow, you set the old node to tabindex="-1", set the new node to tabindex="0", and call .focus() on it. Without this, a naive tree gives every one of its nodes tabindex="0", so a keyboard user has to Tab through hundreds of items to get past the widget, and the arrow-key model never engages. A tree with 400 files would be 400 Tab stops. Roving tabindex collapses that to one. The alternative model is aria-activedescendant, where DOM focus stays on the tree container and an attribute points at the active node; both are valid, but roving tabindex is the more common and more robust choice.
Do I need aria-level, aria-setsize, and aria-posinset on tree items?▾
They tell a screen reader user where they are in the hierarchy, which is exactly the information a sighted user reads from indentation. aria-level is the 1-based depth: the root row is level 1, its children level 2, and so on, so the user hears "level 3" and knows how deep they have gone. aria-setsize and aria-posinset give position within the current branch — "3 of 7" — so the user knows how many siblings there are and which one this is. When the tree is fully present in the DOM and correctly nested with role="group", some assistive technologies can infer level and position, but setting the attributes explicitly is the reliable choice and is essential the moment you virtualise or lazy-load nodes, because then the DOM no longer contains every sibling for the browser to count. Treat all three as part of a properly built tree rather than optional extras.
How do I handle selection in a tree — aria-selected or aria-checked?▾
Use aria-selected. A tree is a selection widget, like a listbox, so the selected state belongs on aria-selected, not aria-checked (which is for checkboxes, switches, and menuitemcheckbox). In a single-select tree, one node carries aria-selected="true" and the rest either carry "false" or omit it; activating a node with Enter selects it. For a multi-select tree, put aria-multiselectable="true" on the role="tree" container and give every node an explicit aria-selected of "true" or "false" so the user can hear the state of each; Space toggles the focused node, and the usual extensions — Shift+Arrow to extend, Ctrl+A to select all — apply. Whatever the model, never convey selection by background colour alone: a screen reader user needs aria-selected, and a colour-blind user needs a non-colour cue such as a check icon or bold label.
How do I test a tree view for accessibility?▾
Start with the keyboard and no mouse at all. Tab once — focus should land on a single node, not step through every one. Use Down and Up to walk the visible nodes, Right to open a branch and step into it, Left to collapse and step back out, Home and End to jump to the ends, and a letter key to type-ahead to a node. Confirm the page never scrolls out from under you and that focus is always visible. Then listen with a screen reader: each node should announce its label, the word "tree item", its level, its position ("3 of 7"), and — for parents — whether it is expanded or collapsed, and — in a selection tree — whether it is selected. Collapse a branch while focus is on one of its children and confirm focus jumps to the parent rather than vanishing. Finally, check the visuals: the expand/collapse icon and the focus and selection indicators each need 3:1 contrast, and the clickable row or twisty needs a large enough target.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences