Data Display
Treegrid
AvailableA grid with expandable and collapsible rows. A composite of tree and grid patterns.
What Is a Treegrid?
A treegrid is "a grid (table) where rows can be expanded and collapsed."Email thread lists, file/folder listings, and comment trees are common use cases — whenever you need to handle both "tabular data" and "hierarchical (parent → child) expansion."
Think of it as a regular table (table) with the added ability to collapse rows. Because of this, you need to convey both the table structure (rows, cells, headers) and tree state (expanded / collapsed) to assistive technologies.
Why Does Accessibility Matter?
Building it with visual styling alone leaves these people behind:
- Keyboard-only users. If rows are
<div>elements, they can't be focused with Tab, and there's no way to navigate or expand/collapse rows with arrow keys. - Screen reader users. Without
roleattributes, it's not recognized as a "table" or "row," so there's no way to know which cell belongs to which header, or whether a row is expanded or collapsed.
The solution is to convey structure with role="treegrid" / role="row" / role="gridcell", add aria-expanded to parent rows to convey expand/collapse state, and enable arrow key navigation.
Live Demo (Recommended Implementation)
Below is an email thread list implemented according to the APG. Try operating it without the mouse, using only the keyboard.
| From | Subject | Date |
|---|---|---|
| Yamada | Year-end party (3 messages) | 12/10 |
| Yamada | Re: Scheduling discussion | 12/10 |
| Sato | Re: Restaurant booked | 12/11 |
| Suzuki | Invoice submission (2 messages) | 12/12 |
| Suzuki | Re: November invoice | 12/12 |
| Accounting Dept. | Re: Payment confirmed | 12/13 |
Try it: Tab into the grid → ↑ ↓ to navigate rows → → to expand a parent row / ← to collapse → Home / End to jump to first / last row.
Tip
Turn on a screen reader (on macOS, press ⌘+F5 to toggle VoiceOver) and you'll hear parent rows announced as "expanded / collapsed" and "level 1, 1 of 2" —expand/collapse state plus hierarchy and position. This is the effect of aria-expanded and aria-level / aria-posinset / aria-setsize.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| Tab | Enter / leave the grid (only one tab stop inside) | Required |
| ↓ / ↑ | Move focus to the next / previous visible row | Required |
| → | Expand a collapsed parent row. If already expanded or a leaf, move to the first cell. On a cell, move right | Required |
| ← | Collapse an expanded parent row. No action on a collapsed row. On a cell, move left (from the first cell, move to the row) | Required |
| Home / End | Move to the first / last visible row | Recommended |
Note
The treegrid acts as a single tab stop. Pressing Tab repeatedly won't stop at each row. Once inside, use arrow keys to navigate rows. This is achieved with roving tabindex(only the currently selected row has tabindex="0", others have -1).
Required WAI-ARIA Roles & Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Outer container | role="treegrid" + aria-label | Conveys that it is a collapsible table. Also provide a label (name). |
| Each row | role="row" | Identifies a single row in the table. |
| Header cell | role="columnheader" | Column header. Associates each cell with its column. |
| Data cell | role="gridcell" | A single cell within a row. |
| Expandable parent row | aria-expanded="true | false" | Whether the row is expanded. Must be updated on toggle. |
| Each row | aria-level / aria-posinset / aria-setsize | Depth level and position among siblings (optional but recommended). |
| Row (focus management) | tabindex="0 | -1" | Roving tabindex. Only the current row gets 0; all others get -1. |
| Collapsed child rows | hidden | While collapsed, hide from the DOM and exclude from keyboard and screen reader access. |
Recommended Pattern (Good)
Good / Recommended
Layer role="treegrid" roles on top of a native <table>, and synchronize parent row aria-expanded and roving tabindex with JavaScript.
Markup:
<table role="treegrid" aria-label="Inbox">
<thead>
<tr role="row">
<th role="columnheader">From</th>
<th role="columnheader">Subject</th>
<th role="columnheader">Date</th>
</tr>
</thead>
<tbody>
<!-- Parent row: expandable. aria-expanded conveys open/close state -->
<tr role="row"
aria-level="1" aria-posinset="1" aria-setsize="2"
aria-expanded="true" tabindex="0">
<td role="gridcell">Yamada</td>
<td role="gridcell">Year-end party (3 messages)</td>
<td role="gridcell">12/10</td>
</tr>
<!-- Child rows: hidden while parent is collapsed -->
<tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
<td role="gridcell">Yamada</td>
<td role="gridcell">Re: Scheduling discussion</td>
<td role="gridcell">12/10</td>
</tr>
<tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
<td role="gridcell">Sato</td>
<td role="gridcell">Re: Restaurant booked</td>
<td role="gridcell">12/11</td>
</tr>
</tbody>
</table>Keyboard interaction and state synchronization script:
const grid = document.querySelector('[role="treegrid"]');
const rows = Array.from(grid.querySelectorAll('tbody [role="row"]'));
// Collect only currently operable (visible) rows
const visibleRows = () => rows.filter((r) => !r.hidden);
// Roving tabindex: only one row has tabindex="0", the rest are "-1"
function focusRow(row) {
rows.forEach((r) => r.setAttribute('tabindex', r === row ? '0' : '-1'));
row.focus();
}
// Toggle child rows (one level deeper) following a parent row
function setExpanded(row, open) {
row.setAttribute('aria-expanded', String(open));
let next = row.nextElementSibling;
while (next && next.getAttribute('aria-level') === '2') {
next.hidden = !open; // Hidden rows are excluded from keyboard/screen reader
next = next.nextElementSibling;
}
}
grid.addEventListener('keydown', (e) => {
const row = e.target.closest('[role="row"]');
if (!row) return;
const list = visibleRows();
const i = list.indexOf(row);
const expandable = row.hasAttribute('aria-expanded');
const isOpen = row.getAttribute('aria-expanded') === 'true';
let target = null;
switch (e.key) {
case 'ArrowDown': target = list[Math.min(i + 1, list.length - 1)]; break;
case 'ArrowUp': target = list[Math.max(i - 1, 0)]; break;
case 'Home': target = list[0]; break;
case 'End': target = list[list.length - 1]; break;
case 'ArrowRight':
if (expandable && !isOpen) { e.preventDefault(); setExpanded(row, true); }
return;
case 'ArrowLeft':
if (expandable && isOpen) { e.preventDefault(); setExpanded(row, false); }
return;
default: return;
}
if (target) { e.preventDefault(); focusRow(target); }
});Note
Two key points: (1) Arrow key navigation targets only "visible rows"(don't send focus to collapsed child rows). (2) When toggling with →/←, always keep aria-expanded and child row hidden in sync. Changing only the visual appearance won't communicate anything to assistive technologies.
Anti-Pattern (Bad)
Below is a "visually identical" thread list built with <div> and onclick only.It works with a mouse but is completely inoperable via keyboard.Try pressing Tab and arrow keys to feel the difference from the demo above.
Try it: Tab doesn't focus any row, arrow keys can't navigate or expand. Screen readers don't recognize it as a 'table' or 'expandable row.'
<!-- ❌ Anti-pattern -->
<div class="grid">
<div class="head">From / Subject / Date</div>
<!-- div elements can't receive focus, and have no row/cell/expand semantics -->
<div class="row" onclick="toggle('thread')">
Yamada | Year-end party (3 messages) | 12/10 +
</div>
<div id="thread" style="display:none">
<div class="row">Yamada | Re: Scheduling discussion | 12/10</div>
<div class="row">Sato | Re: Restaurant booked | 12/11</div>
</div>
</div>Bad / Avoid
Problems with this implementation:
- No table/row/cell semantics — Without
role="treegrid"etc., screen readers hear "just a block of text." There's no cell-to-column association. - Expand/collapse state not conveyed — Without
aria-expanded, users can't tell if a row is open or closed. - Not keyboard operable —
divelements can't receive focus, so arrow key navigation, expansion, and collapse don't work. - No roving tabindex — Focus management doesn't exist at all.
- Relies on inline
display:nonestyle, with nohiddenattribute or state management.
Tip
If you must use <div>, you'll need to add role, tabindex, arrow key handling, and aria-expanded all by yourself. Starting with a native <table> gives you row, cell, and header associations for free.
Implementation Checklist
- The outer container has role="treegrid" and aria-label (name)
- Rows use role="row"; cells use role="gridcell" / role="columnheader"
- Expandable parent rows have aria-expanded that always matches their open/closed state
- Collapsed child rows are hidden from keyboard and screen readers
- Only one tab stop inside the grid; Up/Down arrows move between rows (roving tabindex)
- Right arrow expands (if expanded, moves to the first cell); Left arrow collapses (on a cell, moves left or to the row)
- (Optional) Home / End move to the first and last rows
- (Optional) aria-level / aria-posinset / aria-setsize convey hierarchy and position
- Operable by keyboard alone with visible focus (focus ring is not removed)
Source (English):Treegrid Pattern — W3C APG(opens in a new tab)