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.

Anatomy of an expanded detail row Mock table where row two is expanded: the disclosure button in its first cell, and a full-width detail row beneath it labelled with the parent's order number. Anatomy of an expanded detail rowOrderCustomerStatusTotal▸ SO-2201NorthwindShipped1,280.00▾ SO-22021ContosoPending420.00Details23 line itemsNotes—▸ SO-2203FabrikamShipped96.001Button with aria-expanded="true" andaria-controls pointing at the detail row2One td spanning all columns, labelled withthe parent's order number
The detail row follows its parent in source order — the property linear reading depends on.

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.

Expanding and reading one detail row Timeline from focusing the disclosure button to hearing its collapsed state, activating it, hearing expanded, and reading into the detail row. Expanding and reading one detail rowTab to button"Details for SO-2202, button,collapsed"Enterrow inserted after parentAnnouncement"expanded"Down Arrow"Details for order SO-2202, region"Read onthe line items tablekeyboard and screen reader sequence
The state change is announced from the button itself — no live region is needed.

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.

Detail row versus treegrid row Comparison of the detail-row disclosure pattern and the treegrid row pattern across structure, keyboard model and markup. Detail row versus treegrid row✓ Detail row (this page)Table stays a static tableButton in the first cell owns aria-expandedRevealed content has any shapeTab and Enter; no arrow-key model✓ Treegrid rowrole="treegrid" with focusable rowsThe row owns aria-expanded and aria-levelRevealed content is more rows, same columnsRight and Left Arrow expand and collapse
Both expand, but the detail row keeps the table static; the treegrid makes rows interactive.

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.

Permalink to "Related"

← Back to Expandable Rows & Nested Data