Collapsing Row Groups With aria-expanded
Permalink to "Collapsing Row Groups With aria-expanded"A collapsible row group lets users fold away the rows of a group and keep only its header — often with the group’s count and subtotal still visible. It is how long grouped reports become scannable: collapse everything, read the twelve group headers, expand the two that matter. The accessibility failure is a collapse toggle that screen reader users cannot find or cannot read the state of, or collapsed rows that remain in the accessibility tree so the “collapsed” table still reads in full.
There are two correct implementations, depending on whether the table is static or an interactive grid. Both are covered here. The page belongs to row grouping & totals in data tables and builds on accessible group header rows.
Spec reference
Permalink to "Spec reference"Static table. aria-expanded goes on a <button> inside the <th scope="rowgroup">. The row itself cannot carry it — table rows do not support the state. Collapsed rows get the hidden attribute (or are unmounted), removing them from rendering and from the accessibility tree.
Treegrid. In role="treegrid", rows are focusable and support aria-expanded, aria-level, aria-posinset and aria-setsize. A group row is aria-level="1" with aria-expanded; its members are aria-level="2". Right Arrow expands, Left Arrow collapses (or moves to the parent from a child).
Criteria in play: SC 4.1.2 Name, Role, Value for the expanded state; SC 1.3.1 for keeping the grouping and summary programmatic while collapsed; SC 4.1.3 for expand-all and collapse-all results, which affect content away from focus.
When to use collapsible groups — and when not to
Permalink to "When to use collapsible groups — and when not to"Use them when a grouped table is long and users typically need a few groups: financial statements by account, logs by service, tickets by assignee. Collapsing turns a 400-row read into a 12-row overview.
Do not collapse groups by default without saying so. A report that loads with every group collapsed looks, by ear, like a table of twelve rows. Include the count in each group header (“North, 38 invoices, collapsed”) so the content that is hidden is still described.
The misapplication to name is putting aria-expanded on the <tr> of a static table and a click handler on the row. It is not announced, and the row is not focusable, so keyboard users cannot toggle it at all.
Annotated code example
Permalink to "Annotated code example"<!-- STATIC TABLE: disclosure button inside the rowgroup header -->
<tbody id="grp-north">
<tr class="group-header">
<th scope="rowgroup" colspan="3">
<!-- SC 4.1.2: button owns the state; name includes count and subtotal -->
<button type="button" aria-expanded="true" aria-controls="grp-north">
<span aria-hidden="true" class="chevron"></span>
North <span class="meta">— 2 invoices, subtotal 1,610.00</span>
</button>
</th>
</tr>
<tr data-member><th scope="row">INV-1042</th><td>Northwind</td><td>1,280.00</td></tr>
<tr data-member><th scope="row">INV-1051</th><td>Litware</td><td>330.00</td></tr>
</tbody>
function toggleGroup(btn) {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
// SC 1.3.1: hidden rows leave the accessibility tree; header stays
document.getElementById(btn.getAttribute('aria-controls'))
.querySelectorAll('tr[data-member]')
.forEach((tr) => { tr.hidden = open; });
// Focus stays on the button; its state change is the announcement
}
function setAllGroups(expand) {
document.querySelectorAll('.group-header button').forEach((b) => {
if ((b.getAttribute('aria-expanded') === 'true') !== expand) toggleGroup(b);
});
// SC 4.1.3: the effect is away from focus, so say what happened
status(expand ? 'All 12 groups expanded, 412 rows.' : 'All 12 groups collapsed.');
}
<!-- TREEGRID: the group row itself carries the state -->
<div role="treegrid" aria-label="Open invoices by region">
<div role="row" aria-level="1" aria-expanded="true" aria-posinset="1" aria-setsize="2" tabindex="0">
<div role="gridcell">North — 2 invoices</div>
<div role="gridcell">1,610.00</div>
</div>
<div role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
<div role="gridcell">INV-1042</div><div role="gridcell">1,280.00</div>
</div>
</div>
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Static table (button) | Treegrid (row) |
|---|---|---|
| Reach the toggle | Tab: “North — 2 invoices, subtotal 1,610.00, button, expanded” |
Arrow to row: “North — 2 invoices, 1,610.00, expanded, level 1, 1 of 2” |
| Collapse | Enter/Space: “collapsed” |
Left Arrow: “collapsed” |
| Expand | Enter/Space: “expanded” |
Right Arrow: “expanded” |
| Browse past a collapsed group | Next group header is read; members skipped | Members not rendered or not reachable |
| Collapse all | “All 12 groups collapsed.” | Same, through the status region |
Integration context
Permalink to "Integration context"The treegrid variant is the same mechanism as expandable hierarchical rows, covered in depth in building an accessible treegrid with expandable rows. If groups load their members on demand, the loading contract in lazy-loading child rows in a treegrid applies unchanged.
Collapsed groups should keep their subtotal visible, which is one reason the subtotal belongs in the header when groups are collapsible, rather than only in a trailing row that disappears with the members; see announcing totals and summary rows.
A collapsed group with an informative header is a summary in its own right. Many users never expand it: the subtotal and count are what they came for, and the rows beneath are detail they can reach if needed. That is the real accessibility win of collapsible groups — not hiding content, but offering a readable summary layer on top of it.
Gotchas
Permalink to "Gotchas"Hiding with display: none on a class. Works, but it is easy for a later style change to leave rows visually hidden and in the tree, or the reverse. The hidden attribute is harder to get wrong.
Focus inside a collapsing group. In a treegrid, collapsing from a child row should move focus to the group row. In a static table, focus is on the button, so this cannot happen.
Row counts in the caption. If the caption says “412 invoices” and all groups are collapsed, the table exposes 12 rows. That is correct; the caption describes the data, the rows describe the view. Keep both honest.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Where does aria-expanded go for a collapsible group in a regular table?
On a button inside the group header cell. Rows of a static table do not support aria-expanded, so the state must live on a focusable control that does.
Should collapsed rows be hidden with aria-hidden?
Use the hidden attribute or unmount them instead. That removes them visually and from the accessibility tree together, so the visual and spoken tables always match.
Should groups start collapsed?
Only if the group headers carry enough information — name, row count and subtotal — to be useful on their own, and the collapsed state is announced. Otherwise start expanded and offer collapse-all.
Do expand all and collapse all need an announcement?
Yes. They change content far from the focused control, so a status message such as “All 12 groups collapsed” confirms the result without moving focus.
Related
Permalink to "Related"- Group header rows — the static markup this extends
- Treegrid with expandable rows — the interactive model
- Expandable detail rows in React — the same disclosure for one record