Expandable Rows & Nested Data in Accessible Tables
Permalink to "Expandable Rows & Nested Data in Accessible Tables"Hierarchical tabular data — order line items, org-chart entries, multi-level inventory trees — requires precise DOM structuring and state synchronization. The failure mode this pattern prevents is silent information loss: when a toggle button changes visual appearance but fails to update ARIA attributes or remove content from the accessibility tree, screen reader users receive no indication that nested data exists or that anything changed.
This page covers the complete implementation contract for expand/collapse rows: semantic markup, ARIA attribute mapping, keyboard behaviour, screen reader compatibility, and testing. It builds on the semantic HTML table construction foundations that govern valid <table> structure, and assumes familiarity with ARIA live regions for dynamic data for state announcements.
WCAG Criteria in Scope
Permalink to "WCAG Criteria in Scope"| Criterion | Level | Relevance to this pattern |
|---|---|---|
| 1.3.1 Info and Relationships | A | Structural relationships between parent rows and nested content must be programmatically determinable |
| 1.3.2 Meaningful Sequence | A | Detail rows must immediately follow their parent in DOM order so linearised reading is coherent |
| 2.1.1 Keyboard | A | All expand/collapse controls must be operable by keyboard alone |
| 2.4.3 Focus Order | A | Focus must move predictably into and out of expanded content |
| 4.1.2 Name, Role, Value | A | Toggle button must expose its current state (aria-expanded) and the relationship to its controlled region (aria-controls) |
Prerequisites
Permalink to "Prerequisites"Before implementing this pattern, ensure you understand:
- Semantic HTML table construction — valid
<table>element hierarchy andscopeattribute usage - Correct usage of
scopeandheadersin complex tables — multi-column header association - Focus management in single-page apps — how to route focus programmatically without losing context
- ARIA live regions for dynamic data — polite vs assertive announcement timing
ARIA & HTML Spec Reference
Permalink to "ARIA & HTML Spec Reference"aria-expanded (on the toggle button)
Permalink to "aria-expanded (on the toggle button)" - Valid values:
true,false(string or boolean coerced to string) - When to apply: on any interactive control that shows or hides a region
- Common misuse: placing
aria-expandedon the detail row itself instead of the button; placing it on the parent<tr>instead of the button within it
aria-controls (on the toggle button)
Permalink to "aria-controls (on the toggle button)" - Valid values: a space-separated list of
idreferences pointing to the controlled elements - When to apply: pair with
aria-expandedto explicitly associate the button with its detail region - Common misuse: using
aria-controlswithoutaria-expanded; referencing an ID that doesn’t exist or changes after a sort
hidden attribute (on the detail row)
Permalink to "hidden attribute (on the detail row)" - Effect: sets
display: noneand removes the element from the accessibility tree — the correct behaviour for collapsed content - When to apply: on
<tr class="detail-row">when collapsed; remove on expand - Common misuse: using
aria-hidden="true"alongsidehiddenon the same element (redundant); usingvisibility: hiddenalone (element remains in accessibility tree)
role="treegrid" (alternative for multi-level hierarchies)
Permalink to "role="treegrid" (alternative for multi-level hierarchies)" - When to apply: only when the table has two or more nesting levels that require the treegrid keyboard model (Arrow-key cell navigation,
aria-levelon rows,aria-expandedon row elements) - When NOT to apply: single-level expand/collapse; use a plain
<table>with hidden detail rows instead
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"Step 1 — Structure valid parent and detail rows (WCAG 1.3.1, 1.3.2)
Permalink to "Step 1 — Structure valid parent and detail rows (WCAG 1.3.1, 1.3.2)"Never break the <table> → <tbody> → <tr> → <td> hierarchy with arbitrary <div> wrappers. The detail row must be an immediate sibling of its parent <tr>, not nested inside a <td>.
<!-- WCAG 1.3.1: structure communicates parent/detail relationship -->
<table id="orders-table">
<thead>
<tr>
<th scope="col">Order ID</th>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody>
<!-- Parent row -->
<tr class="parent-row" id="row-101">
<td>ORD-101</td>
<td>Acme Corp</td>
<td>Processing</td>
<td>
<!-- WCAG 4.1.2: aria-expanded on the button, aria-controls references detail row -->
<button
type="button"
aria-expanded="false"
aria-controls="detail-101"
class="expand-toggle">
<span class="visually-hidden">Show details for order</span> ORD-101
</button>
</td>
</tr>
<!-- Detail row: hidden attribute removes from accessibility tree when collapsed -->
<tr id="detail-101" class="detail-row" hidden>
<td colspan="4">
<div class="nested-content">
<!-- Line items, shipping info, or nested tables go here -->
</div>
</td>
</tr>
</tbody>
</table>
Step 2 — Synchronise ARIA state on toggle (WCAG 4.1.2)
Permalink to "Step 2 — Synchronise ARIA state on toggle (WCAG 4.1.2)"Read the current aria-expanded value, invert it, and apply the matching hidden state to the detail row atomically. Never leave these two attributes out of sync.
function toggleRow(button) {
// WCAG 4.1.2: read current state before mutating
const isExpanded = button.getAttribute('aria-expanded') === 'true';
const detailRow = document.getElementById(
button.getAttribute('aria-controls') // must resolve to a valid element
);
if (!detailRow) {
console.error('aria-controls references a missing ID:', button.getAttribute('aria-controls'));
return;
}
// Flip expanded state on the button
button.setAttribute('aria-expanded', String(!isExpanded));
// Toggle hidden on the detail row (true = hidden, false = visible)
detailRow.toggleAttribute('hidden', isExpanded);
}
// Attach to all toggle buttons
document.querySelectorAll('.expand-toggle').forEach(btn => {
btn.addEventListener('click', () => toggleRow(btn));
});
Step 3 — Announce state changes (WCAG 4.1.3)
Permalink to "Step 3 — Announce state changes (WCAG 4.1.3)"After toggling, dispatch a polite announcement so screen reader users receive confirmation without aria-live interrupting ongoing reading. See choosing between polite and assertive aria-live regions for the decision rule.
<!-- Place once in the page; outside the table -->
<div
id="a11y-announcer"
role="status"
aria-live="polite"
aria-atomic="true"
class="visually-hidden">
</div>
function announceToggle(expanded, label) {
const region = document.getElementById('a11y-announcer');
// Clear first so repeat toggles re-trigger announcement
region.textContent = '';
// Schedule on next microtask so the DOM reset is observed
requestAnimationFrame(() => {
region.textContent = expanded
? `${label} details expanded`
: `${label} details collapsed`;
});
}
function toggleRow(button) {
const isExpanded = button.getAttribute('aria-expanded') === 'true';
const detailRow = document.getElementById(button.getAttribute('aria-controls'));
if (!detailRow) return;
button.setAttribute('aria-expanded', String(!isExpanded));
detailRow.toggleAttribute('hidden', isExpanded);
// WCAG 4.1.3: provide status announcement
const label = button.textContent.trim();
announceToggle(!isExpanded, label);
}
Step 4 — Restore focus on collapse (WCAG 2.4.3)
Permalink to "Step 4 — Restore focus on collapse (WCAG 2.4.3)"When a user collapses a row, any focused element inside the detail row disappears. Return focus to the toggle button before hiding the row.
function toggleRow(button) {
const isExpanded = button.getAttribute('aria-expanded') === 'true';
const detailRow = document.getElementById(button.getAttribute('aria-controls'));
if (!detailRow) return;
if (isExpanded) {
// WCAG 2.4.3: move focus before hiding to prevent focus loss
const focusedInside = detailRow.contains(document.activeElement);
if (focusedInside) button.focus();
}
button.setAttribute('aria-expanded', String(!isExpanded));
detailRow.toggleAttribute('hidden', isExpanded);
announceToggle(!isExpanded, button.textContent.trim());
}
Step 5 — Apply motion-safe CSS for visual state (WCAG 2.3.3)
Permalink to "Step 5 — Apply motion-safe CSS for visual state (WCAG 2.3.3)"Style the toggle indicator via a CSS attribute selector targeting the button. Respect prefers-reduced-motion so the expand animation does not trigger vestibular symptoms.
/* Rotate an icon inside the toggle button when expanded */
.expand-toggle[aria-expanded="true"] .icon {
transform: rotate(90deg);
transition: transform 0.2s ease;
}
/* WCAG 2.3.3: honour the user's motion preference */
@media (prefers-reduced-motion: reduce) {
.expand-toggle .icon {
transition: none;
}
}
/* Focus indicator — WCAG 2.4.11: non-zero focus appearance */
.expand-toggle:focus-visible {
outline: 3px solid currentColor;
outline-offset: 2px;
}
ARIA State Flow Diagram
Permalink to "ARIA State Flow Diagram"The diagram below shows the two-state cycle of the toggle button and the detail row, and the attributes that must stay synchronised at each transition.
Keyboard Interaction Contract
Permalink to "Keyboard Interaction Contract"| Key | Action | Expected AT Announcement | Failure Indicator |
|---|---|---|---|
Enter or Space |
Toggle expand/collapse on focused button | “ORD-101 details expanded” or “collapsed” | No announcement; aria-expanded not updated |
Tab |
Move focus into nested interactive elements when row is expanded | Focus lands on first focusable element in detail row | Focus skips over detail row or lands in collapsed row |
Escape |
Collapse expanded row and return focus to toggle button | “collapsed” announcement | Focus lost; detail row hidden but aria-expanded still true |
ArrowDown / ArrowUp |
Move between visible rows in a treegrid | Row announcement with level and position | Arrows trapped in detail content; no movement |
Screen Reader Compatibility Matrix
Permalink to "Screen Reader Compatibility Matrix"| AT + Browser | aria-expanded announcement |
State change announcement | Known deviation |
|---|---|---|---|
| NVDA + Firefox | “collapsed button” / “expanded button” | Reads live region after small delay | May re-read button label on focus return |
| NVDA + Chrome | “collapsed” suffix on button | Live region announces reliably | None |
| JAWS + Chrome | “collapsed” announced inline | Reads live region immediately | aria-controls must resolve or JAWS ignores state |
| VoiceOver + Safari (macOS) | “collapsed” in button description | Live region announced on rotor | May not re-announce if text content is identical |
| VoiceOver + Safari (iOS) | “collapsed” spoken after button label | Live region announced | Double-tap required; swiping past button skips state |
| TalkBack + Chrome (Android) | “collapsed” after button name | Live region announced | Requires role="button" on non-native elements |
Edge Cases & Failure Modes
Permalink to "Edge Cases & Failure Modes"1. Broken aria-controls reference after DOM reorder
Sort and filter operations frequently reassign DOM positions while ID attributes remain stable. However, if your framework re-generates IDs on re-render (e.g. using array index as key), the button’s aria-controls value points to a stale or non-existent element. JAWS silently ignores broken references; VoiceOver falls back to announcing only aria-expanded. Fix: key detail-row IDs to stable entity identifiers (detail-order-${orderId}), not array positions.
2. aria-expanded and hidden out of sync during async operations
When a toggle triggers an API call to load nested data, there is a window where aria-expanded="true" is set (indicating the row is open) but the detail row is still hidden while data loads. Screen readers announce “expanded” but present no content. Fix: set aria-expanded="true" and aria-busy="true" on the detail row simultaneously. Remove hidden only after content is injected, then clear aria-busy.
async function toggleRowWithLoad(button) {
const detailRow = document.getElementById(button.getAttribute('aria-controls'));
const isExpanded = button.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
// WCAG 4.1.2: signal loading state before content arrives
button.setAttribute('aria-expanded', 'true');
detailRow.setAttribute('aria-busy', 'true');
detailRow.removeAttribute('hidden');
detailRow.innerHTML = '<td colspan="4"><span>Loading…</span></td>';
const data = await fetchRowDetails(button.dataset.rowId);
detailRow.innerHTML = renderDetails(data); // inject real content
detailRow.removeAttribute('aria-busy');
} else {
if (detailRow.contains(document.activeElement)) button.focus();
button.setAttribute('aria-expanded', 'false');
detailRow.setAttribute('hidden', '');
}
}
3. Focus trapped inside collapsed content after rapid toggling
If a user activates Tab before the collapse animation completes, focus can land inside a partially visible detail row that is then hidden. The element retains focus but is invisible. Fix: use requestAnimationFrame to defer the hidden attribute toggle until after the collapse animation, or disable pointer/keyboard events on the detail row with inert while animating.
4. Virtualized grids discard DOM nodes for off-screen rows
In a virtualised table (e.g. react-window), a row scrolled out of viewport is unmounted and replaced with a new DOM node on scroll-back. The new node loses the expanded state stored in the DOM. Fix: store expansion state in your application’s data layer keyed to row ID. On mount, read from that store and apply aria-expanded and hidden before the component paints.
5. Nested <table> inside a detail row
When detail content contains its own <table> (e.g. line-item breakdown), screen readers may linearise the outer and inner table headers together confusingly. Fix: give the nested table a <caption> that labels its scope (e.g. “Line items for ORD-101”). Associate nested header cells using correct scope and headers attribute usage.
Dynamic Sorting & Filtering Integration
Permalink to "Dynamic Sorting & Filtering Integration"Parent grid mutations must preserve or intentionally reset nested state. Sorting and filtering operations can detach detail rows from their parents and break aria-controls references if ID generation is not stable.
Reference sortable and filterable data grids for the sort-state announcement protocol and aria-sort attributes for accessible column filtering for the aria-sort lifecycle that runs in parallel with expansion state.
Auto-collapse on filter: Reset all aria-expanded states to false and apply hidden to all detail rows when the dataset changes. Announce “Table filtered — all rows collapsed” via the polite live region.
Preserve-state on sort: Store expansion flags in a Map keyed to row ID before sorting. After the DOM updates, re-apply aria-expanded and hidden state from that map.
// Preserve expansion state across sort operations
function sortTable(columnIndex, direction) {
// Snapshot current state
const expansionState = new Map();
document.querySelectorAll('.expand-toggle').forEach(btn => {
const rowId = btn.closest('tr').id;
expansionState.set(rowId, btn.getAttribute('aria-expanded') === 'true');
});
// Re-render sorted rows … (framework-specific)
renderSortedRows(columnIndex, direction);
// Restore state after render
requestAnimationFrame(() => {
document.querySelectorAll('.expand-toggle').forEach(btn => {
const rowId = btn.closest('tr').id;
const wasExpanded = expansionState.get(rowId) ?? false;
btn.setAttribute('aria-expanded', String(wasExpanded));
const detailRow = document.getElementById(btn.getAttribute('aria-controls'));
if (detailRow) detailRow.toggleAttribute('hidden', !wasExpanded);
});
});
}
Further Reading
Permalink to "Further Reading"Keyboard Navigation Patterns for Paginated Data Views
Permalink to "Keyboard Navigation Patterns for Paginated Data Views"When expandable rows appear inside paginated tables, focus management spans page boundaries. If a user expands a row on page 3, navigates forward to page 4, then back to page 3, the expanded row must be visible and focusable in the restored scroll position. See keyboard navigation patterns for paginated data views for the complete focus restoration contract and the aria-rowcount / aria-rowindex attributes that maintain positional context across pages.
Key interaction notes specific to paginated contexts:
- Store expanded row IDs in session storage or URL params so state survives page transitions
- On page load, apply expansion state before first paint to avoid FOUC (flash of collapsed content)
- Update
aria-rowindexon parent rows after pagination — the index reflects position in the full dataset, not the current page
Testing Checklist
Permalink to "Testing Checklist"Automated
Keyboard-only
AT manual verification
Layout & motion
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Should I use role="treegrid" or a plain table for expandable rows?
Use a plain <table> with hidden detail rows for single-level expand/collapse. Switch to role="treegrid" only when your hierarchy has two or more nesting levels that require the treegrid keyboard model — Arrow keys navigate between cells, aria-level marks row depth, and aria-expanded goes on the row element itself rather than a button within it. The treegrid pattern requires significantly more ARIA scaffolding and has inconsistent screen reader support; reach for it only when the data genuinely demands multiple hierarchy levels.
Why does aria-expanded go on the button, not the detail row?
The ARIA specification defines aria-expanded as the state of a disclosure widget — the control that opens or closes something, not the region that is revealed. The detail row is the controlled region; its visibility is communicated by the hidden attribute. Placing aria-expanded on the detail row instead of the button causes screen readers to announce state on the wrong element, and JAWS may ignore the attribute entirely on non-button roles.
How do I preserve expanded state when sorting or filtering the table?
Store expansion flags in application state keyed to stable row identifiers (e.g. order IDs, not array indices) before triggering a sort or filter. After the DOM re-renders, read the flags and set aria-expanded and hidden on each matching row. Announce the outcome — “Table sorted, 3 rows remain expanded” — via the polite aria-live region so keyboard-only users know what happened without having to survey the whole table.
Related
Permalink to "Related"- Keyboard navigation patterns for paginated data views — focus restoration across page boundaries in tables with expandable rows
- Sortable & filterable data grids — sort-state announcements and aria-sort lifecycle that runs alongside expansion state
- ARIA live regions for dynamic data — polite vs assertive announcement strategy for toggle confirmations
- Focus management in single-page apps — programmatic focus routing after DOM mutations
- Implementing roving tabindex for custom data grids — roving tabindex as an alternative navigation model for row-level controls