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:


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-expanded on 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 id references pointing to the controlled elements
  • When to apply: pair with aria-expanded to explicitly associate the button with its detail region
  • Common misuse: using aria-controls without aria-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: none and 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" alongside hidden on the same element (redundant); using visibility: hidden alone (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-level on rows, aria-expanded on 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.

ARIA state flow for expandable rows Two states — Collapsed and Expanded — connected by a Toggle button action in each direction. Collapsed state: button aria-expanded false, detail row hidden. Expanded state: button aria-expanded true, detail row visible. Collapsed button aria-expanded="false" detail row hidden (in DOM) Expanded button aria-expanded="true" detail row visible (no hidden) Toggle (Enter/Space/click) Toggle / Escape aria-live="polite" region announces 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-rowindex on 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.


Permalink to "Related"

← Back to Accessible Data Tables & Grid Systems