Building an Accessible Treegrid with Expandable Rows
Permalink to "Building an Accessible Treegrid with Expandable Rows"A treegrid is a grid whose rows form a hierarchy: parent rows expand to reveal child rows, and users move through the tree with arrow keys. This guide builds one with the correct roles and states — role="treegrid", aria-expanded, aria-level, aria-posinset, aria-setsize — a Right/Left arrow expand-collapse contract, a roving tabindex, and the focus rule that keeps a user from being stranded when a collapse removes the row they were on. The failure it prevents: a nested grid where expansion is mouse-only, position in the hierarchy is never announced, and collapsing a branch drops focus to document.body. It is the composite-widget path within the expandable rows and nested data section.
Spec reference
Permalink to "Spec reference"role="treegrid" (ARIA 1.2) is a grid that additionally supports expanding and collapsing rows, combining grid navigation with tree semantics. The load-bearing attributes on its rows:
| Attribute | Host | Meaning |
|---|---|---|
role="treegrid" |
container | The grid supports hierarchical rows and arrow-key navigation |
role="row" |
each row | A row within the treegrid |
aria-expanded |
parent rows | true when children are shown, false when collapsed; absent on leaf rows |
aria-level |
rows | 1-based depth in the hierarchy |
aria-posinset |
rows with siblings | 1-based index among siblings at the same level |
aria-setsize |
rows with siblings | Total number of siblings at that level |
Default behaviour: a leaf row (one that can never expand) must not carry aria-expanded — its presence tells the user the row is expandable. aria-level, aria-posinset, and aria-setsize are scoped to the branch, not the flattened grid: a child that is second of three under its parent is aria-posinset="2" aria-setsize="3" regardless of its absolute row number.
Navigation uses a roving tabindex: exactly one row (or cell) has tabindex="0" and is the single tab stop; every other row has tabindex="-1". Arrow keys move the 0 between rows and call .focus().
WCAG criteria in scope: 1.3.1 Info and Relationships (Level A) (hierarchy conveyed programmatically), 2.1.1 Keyboard (Level A), 2.4.3 Focus Order (Level A), and 4.1.2 Name, Role, Value (Level A).
When to use vs. when NOT to
Permalink to "When to use vs. when NOT to"Use role="treegrid" when arrow-key navigation through a hierarchy with expand/collapse is the primary interaction — a file explorer, an org chart, a nested category manager.
Do NOT:
- Use
treegridfor occasionally-expanded rows. If the data is mostly read and rarely expanded, a native<table>with anaria-expandedbutton inside the parent row keeps reading-mode navigation and is far less code. That approach is the default in expandable rows and nested data. - Put
aria-expandedon leaf rows. It falsely advertises expandability. - Leave positional attributes stale. After an expand or collapse changes the visible set, recompute
aria-posinset/aria-setsizeor the announced position drifts. - Use positive
tabindex. The roving pattern uses only0and-1.
Annotated code example
Permalink to "Annotated code example"<!-- WCAG 1.3.1 / 4.1.2: treegrid exposes hierarchy + expandable state -->
<table role="treegrid" aria-label="Project files">
<tbody>
<!-- Level-1 parent, expanded. tabindex=0 → the single tab stop -->
<tr role="row" aria-level="1" aria-posinset="1" aria-setsize="2"
aria-expanded="true" tabindex="0">
<td role="gridcell">Design assets</td>
<td role="gridcell">12 items</td>
</tr>
<!-- Level-2 children of the row above -->
<tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
<td role="gridcell">logo.svg</td>
<td role="gridcell">24 KB</td>
</tr>
<tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
<td role="gridcell">palette.pdf</td>
<td role="gridcell">98 KB</td>
</tr>
<!-- Level-1 parent, collapsed. No child rows in the DOM while collapsed -->
<tr role="row" aria-level="1" aria-posinset="2" aria-setsize="2"
aria-expanded="false" tabindex="-1">
<td role="gridcell">Source code</td>
<td role="gridcell">340 items</td>
</tr>
</tbody>
</table>
// WCAG 2.1.1 / 2.4.3: arrow-key navigation, expand/collapse, and safe focus
const grid = document.querySelector('[role="treegrid"]');
const rows = () => Array.from(grid.querySelectorAll('[role="row"]'));
// Roving tabindex: exactly one row is tabbable; move the 0 and focus it
function focusRow(row) {
rows().forEach(r => r.setAttribute('tabindex', r === row ? '0' : '-1'));
row.focus();
}
grid.addEventListener('keydown', e => {
const row = e.target.closest('[role="row"]');
if (!row) return;
const all = rows();
const i = all.indexOf(row);
const expandable = row.hasAttribute('aria-expanded');
const expanded = row.getAttribute('aria-expanded') === 'true';
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (all[i + 1]) focusRow(all[i + 1]);
break;
case 'ArrowUp':
e.preventDefault();
if (all[i - 1]) focusRow(all[i - 1]);
break;
case 'ArrowRight':
e.preventDefault();
// Collapsed parent → expand; already-expanded → move to first child
if (expandable && !expanded) expandRow(row);
else if (expandable && expanded && all[i + 1]) focusRow(all[i + 1]);
break;
case 'ArrowLeft':
e.preventDefault();
// Expanded parent → collapse; child row → move to its parent
if (expandable && expanded) collapseRow(row);
else moveToParent(row);
break;
}
});
function collapseRow(row) {
// WCAG 2.4.3: if focus is on a descendant being removed, move it to the
// collapsing parent FIRST, or focus is lost to document.body.
const active = document.activeElement;
if (active !== row && isDescendantOf(active, row)) {
focusRow(row);
}
removeDescendantRows(row); // pull child rows out of the DOM
row.setAttribute('aria-expanded', 'false'); // WCAG 4.1.2: state update
recomputePositions(); // refresh aria-posinset/setsize
}
function expandRow(row) {
insertChildRows(row); // add child rows after this row
row.setAttribute('aria-expanded', 'true'); // WCAG 4.1.2: state update
recomputePositions();
}
The critical branch is in collapseRow: before the descendant rows leave the DOM, it checks whether the currently focused element lives inside that subtree and, if so, moves focus up to the collapsing parent. Skip that check and every collapse-while-focused-inside drops the keyboard user onto the document body.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Context | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|---|
Arrow Down / Up |
Any row | Move focus to adjacent row | “Design assets, level 1, expanded, 1 of 2” | Position announced wrong after expand |
Arrow Right |
Collapsed parent | Expand the row | “expanded” | Row expands but state not announced |
Arrow Right |
Expanded parent | Move to first child | “logo.svg, level 2, 1 of 2” | Focus jumps past the children |
Arrow Left |
Expanded parent | Collapse the row | “collapsed” | Children hidden but aria-expanded stale |
Arrow Left |
Child row | Move to parent row | “Design assets, level 1” | Focus lost or stays put |
Tab |
Inside the grid | Leave the treegrid entirely | Next page control | Every row is a tab stop (no roving) |
Integration context
Permalink to "Integration context"This treegrid is the composite-widget end of the expandable rows and nested data section, which also covers the simpler native-table-with-aria-expanded approach and when each is appropriate. The roving tabindex mechanics here — one 0, arrow keys move and focus — are the same pattern documented in implementing roving tabindex for custom data grids; read it for the general algorithm, edge cases around Home/End, and how to seed the initial tab stop. When a treegrid also sorts or its branches update live, pair the position recomputation here with the announcement strategy from aria-live regions for dynamic data.
Gotchas
Permalink to "Gotchas"1. Focus lost on collapse
Permalink to "1. Focus lost on collapse"The defining treegrid bug: collapsing a branch removes its rows, and if focus was inside, it falls to document.body. Always move focus to the collapsing parent before removing descendants. Test it by expanding a branch, arrowing into a child, then pressing Left on the parent — focus must land on the parent, not vanish.
2. Stale aria-posinset after expand/collapse
Permalink to "2. Stale aria-posinset after expand/collapse" If the positional attributes are not recomputed when the visible set changes, a screen reader announces “2 of 3” for a row that is now the only child. Recompute per branch on every expand and collapse.
3. Leaf rows carrying aria-expanded="false"
Permalink to "3. Leaf rows carrying aria-expanded="false"" A leaf row with aria-expanded="false" tells the user it can expand, and pressing Right does nothing — a dead end. Only rows that actually have children get the attribute; true leaves omit it entirely.
FAQ
Permalink to "FAQ"When should I use role=treegrid instead of a table with aria-expanded rows?
Use role="treegrid" when users need to navigate the hierarchy with arrow keys and expand or collapse rows as the primary interaction, the way they would in a file explorer. If the nested data is mostly read and only occasionally expanded, a native table with aria-expanded on a button inside the parent row is simpler and keeps reading-mode navigation. treegrid commits you to a full roving tabindex and arrow-key model, so choose it deliberately.
What happens to focus when I collapse a row whose child is focused?
Collapsing a parent removes its descendant rows from the accessibility tree, so if focus is on one of those descendants it would be lost to the document body. Before collapsing, check whether the focused row is a descendant of the row being collapsed, and if so move focus to the collapsing parent first. The parent is still present, so focus lands somewhere logical and the user keeps their place.
Do I need aria-posinset and aria-setsize on every treegrid row?
Set them on rows that have siblings at the same level so screen readers can announce position such as row 2 of 5 within that branch. aria-setsize is the count of siblings in the current level and aria-posinset is the row’s index among them, both scoped to the parent, not the whole grid. Recompute them when expanding or collapsing changes how many rows are present, otherwise the announced position drifts from reality.
Related
Permalink to "Related"- Expandable rows & nested data — the section covering both treegrid and native-table expansion approaches
- Implementing roving tabindex for custom data grids — the general roving
tabindexalgorithm this treegrid uses - ARIA live regions for dynamic data — announcing branch updates and recomputed positions