Expandable Detail Rows in React With aria-expanded
Permalink to "Expandable Detail Rows in React With aria-expanded"An expandable detail row is a table row with a disclosure button that reveals a second row directly beneath it, holding extra information about the same record — line items of an order, the payload of a log entry, a notes field. The button’s aria-expanded tells the user whether the detail is showing. It prevents the failure where a sighted user sees a chevron rotate and a panel appear, while a screen reader user activates a button that announces nothing and has no idea new content exists below.
This is the simpler sibling of the treegrid pattern. Use it when the expanded content is a detail panel rather than more rows of the same shape. It lives under expandable rows & nested data.
Spec reference
Permalink to "Spec reference"This is the ARIA disclosure pattern applied inside a table. aria-expanded (ARIA 1.2) is a state on the controlling element — here, a <button> — with values true and false. It is not valid on a <tr> in a plain table; only a treegrid row supports it. aria-controls identifies the element whose visibility the button changes; screen reader support for acting on it is patchy (JAWS offers a jump key, others ignore it), so it is a courtesy, not a mechanism to rely on.
The detail row is an ordinary <tr> with one <td colspan> spanning all columns. Hidden detail rows should be removed from the DOM or given the hidden attribute, so that table navigation does not land in them and the row count stays honest.
SC 4.1.2 Name, Role, Value covers the button’s state. SC 1.3.2 Meaningful Sequence covers placement: the detail must come directly after its parent in the DOM so linear reading reaches it next.
When to use detail rows — and when a treegrid
Permalink to "When to use detail rows — and when a treegrid"Use a detail row when the revealed content has a different shape from the table: a form, a small nested table, a paragraph of notes, a chart. The table stays a static table, users read it with table commands, and the only new interactive element is one button per row.
Use a treegrid when expansion reveals more rows of the same columns — a folder tree, an account hierarchy, a bill of materials. There, rows themselves carry aria-expanded, aria-level and aria-posinset, and arrow keys expand and collapse.
The misapplication to name: putting aria-expanded on the <tr> of an ordinary table. It is ignored by most browsers because row in a table context does not support the state, so the only signal users get is whatever the button says — often nothing, if the chevron is an unlabelled icon.
Annotated code example
Permalink to "Annotated code example"import { useId, useState, Fragment } from 'react';
function OrderRow({ order, colCount }) {
const [open, setOpen] = useState(false);
const detailId = useId();
return (
<Fragment>
<tr>
<th scope="row">
{/* SC 4.1.2: native button carries name, role and expanded state */}
<button
type="button"
aria-expanded={open} // true / false, never omitted
aria-controls={detailId} // courtesy link to the detail row
onClick={() => setOpen((o) => !o)}
className="disclosure"
>
<span aria-hidden="true" className="chevron" />
{/* name includes the record, so a list of buttons is distinguishable */}
<span className="visually-hidden">Details for </span>
{order.id}
</button>
</th>
<td>{order.customer}</td>
<td>{order.status}</td>
<td>{order.total}</td>
</tr>
{/* SC 1.3.2: detail follows its parent; rendered only when open */}
{open && (
<tr id={detailId} className="detail-row">
<td colSpan={colCount}>
{/* SC 1.3.1 + 2.4.6: the panel names the record it belongs to */}
<section aria-label={`Details for order ${order.id}`}>
<OrderLines lines={order.lines} />
</section>
</td>
</tr>
)}
</Fragment>
);
}
Focus stays on the button after each toggle — React does not move it, and you should not either. The user expanded the row to read what is below; the next Down Arrow in browse mode, or Tab into interactive detail content, takes them there. Moving focus into the panel automatically is disorienting for keyboard users who were scanning several rows.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Tab to disclosure |
“Details for SO-2202, button, collapsed” | VoiceOver says “collapsed” after a pause |
Enter / Space |
“expanded” | JAWS repeats the full name: “Details for SO-2202, button, expanded” |
Browse-mode Down Arrow |
Reads the detail region label, then content | NVDA announces “row 4” and the colspan cell |
JAWS Insert+Alt+M |
Jumps to the aria-controls target |
Only JAWS implements the jump |
| Collapse with focus in panel | Focus must return to the button | Otherwise focus is lost to body when the row unmounts |
That last row is the one teams miss. If the detail panel contains a “Close” control, or the user collapses from inside, unmounting the row destroys the focused element. Move focus back to the disclosure button before collapsing.
Integration context
Permalink to "Integration context"When expansion requires fetching data, the button should reflect loading, and the detail row should hold a busy placeholder rather than appearing empty; the loading contract for that is in lazy-loading child rows in a treegrid, and it applies equally to detail rows.
Sorting a table that has open detail rows needs a rule: either collapse everything on sort, or move each detail row with its parent. Moving them is kinder, and with stable keys React does it for free — the Fragment keeps parent and detail together.
Gotchas
Permalink to "Gotchas"Icon-only disclosure buttons. A chevron with no text gives a list of identical “button, collapsed” announcements. Include the record identifier in the accessible name, as the example does with visually hidden text.
display: none versus unmounting. Hiding the detail row with CSS keeps it in the DOM; if you do that, use the hidden attribute so it is also removed from the accessibility tree and not counted in the table’s row total.
Colspan drift. When columns can be hidden by the user, a hard-coded colSpan leaves a gap or overflows. Compute it from the visible column count.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should an expandable detail row use a live region?
No. The button’s aria-expanded state change is announced by every screen reader because focus is on the button, and the detail is reachable by reading onward. A live region would duplicate the state announcement.
Can I put aria-expanded on a table row?
Only in a treegrid, where rows are focusable and support the state. In an ordinary table the row role does not support aria-expanded and browsers ignore it. Put the state on a disclosure button inside the row instead.
Should focus move into the detail row when it opens?
No. Keep focus on the disclosure button; the user can read or tab into the detail next. Moving focus automatically disrupts users scanning down a column of rows. The exception is collapsing from inside the panel, where focus must be returned to the button.
Is aria-controls required for an expandable detail row?
It is recommended but not relied upon: only JAWS offers a command to jump to the controlled element. The essential parts are aria-expanded on the button and the detail row placed immediately after its parent in the DOM.
Related
Permalink to "Related"- Accessible treegrid with expandable rows — when children are rows, not details
- Lazy-loading child rows — loading states for expansion
- Collapsing row groups — the same disclosure on group headers