Lazy-Loading Child Rows in a Treegrid

Permalink to "Lazy-Loading Child Rows in a Treegrid"

A lazily loaded treegrid row fetches its children only when the user expands it. The accessibility problem is the gap between the keypress and the data: if nothing changes for 800 milliseconds, a screen reader user has no idea whether the expand worked, failed, or is still running, and a second press collapses a row that never opened. This page covers the sequence of state changes that keeps that gap audible.

It extends the base treegrid pattern and borrows the loading semantics from using aria-busy during progressive table loads.

Spec reference

Permalink to "Spec reference"

In a role="treegrid", each expandable row carries aria-expanded. Child rows carry aria-level (one deeper than the parent), aria-posinset and aria-setsize so that “2 of 14, level 2” can be announced even when the full set is not yet in the DOM.

aria-busy="true" on a container tells assistive technology that its subtree is being updated and changes may be deferred until it is cleared. Support varies: NVDA and JAWS largely ignore it for announcements; VoiceOver can suppress live-region output inside a busy subtree. Use it as a hint, never as the only signal.

Criteria in play: SC 4.1.2 Name, Role, Value for the expanded state; SC 4.1.3 Status Messages for “14 items loaded” and the failure message; SC 2.4.3 Focus Order for keeping focus on the parent row throughout.

The states of one lazy expansion Timeline of a lazily loaded treegrid expansion: key press, expanded state set, placeholder row inserted, data arrives, child rows rendered, status announced. The states of one lazy expansionRight Arrowon a collapsed parent rowaria-expanded=trueannounced immediatelyPlaceholder row"Loading…", aria-busy on the gridResponse14 children arriveRows renderedlevels and positions setStatus"14 items loaded"typically 200–1500 ms end to end
The expanded state changes first, so the keypress is acknowledged before the network responds.

When to lazy-load — and when not to

Permalink to "When to lazy-load — and when not to"

Lazy-load when a subtree is expensive or large: file systems, organisation charts, account hierarchies with thousands of leaves. It keeps the initial DOM small, which matters for the reasons in DOM size limits and performance tradeoffs.

Do not lazy-load small, known subtrees just because the API happens to paginate them. Every lazy expansion adds a loading state the user has to wait through and a failure path they may hit; if the children are a few dozen rows, fetch them with the parent.

The misapplication to call out is setting aria-expanded="true" only after the data arrives. It feels correct — the row is not really expanded until there are children — but it leaves the user with a keypress that produces silence. Acknowledge the intent first; describe the loading state; then describe the result.

Annotated code example

Permalink to "Annotated code example"
// Treegrid row expansion with server-loaded children
async function expandRow(row) {
  if (row.getAttribute('aria-expanded') === 'true') return;
  const grid = row.closest('[role="treegrid"]');
  const level = Number(row.getAttribute('aria-level'));

  // SC 4.1.2: acknowledge the keypress at once
  row.setAttribute('aria-expanded', 'true');

  // One placeholder child, focusable-free, so reading order shows "loading"
  const ph = document.createElement('div');
  ph.setAttribute('role', 'row');
  ph.setAttribute('aria-level', String(level + 1));
  ph.className = 'row row--placeholder';
  ph.innerHTML = '<div role="gridcell">Loading…</div>';
  row.after(ph);
  grid.setAttribute('aria-busy', 'true');           // hint only

  try {
    const children = await fetchChildren(row.dataset.id);
    const frag = document.createDocumentFragment();
    children.forEach((c, i) => {
      const r = renderRow(c);
      r.setAttribute('aria-level', String(level + 1));  // SC 1.3.1
      r.setAttribute('aria-posinset', String(i + 1));
      r.setAttribute('aria-setsize', String(c.total ?? children.length));
      r.setAttribute('tabindex', '-1');                   // roving tabindex
      frag.append(r);
    });
    ph.replaceWith(frag);
    grid.removeAttribute('aria-busy');
    // SC 4.1.3: one polite message once the rows exist
    requestAnimationFrame(() => announce(
      `${children.length} item${children.length === 1 ? '' : 's'} loaded under ${rowName(row)}.`));
  } catch (err) {
    ph.remove();
    grid.removeAttribute('aria-busy');
    row.setAttribute('aria-expanded', 'false');          // honest state
    row.focus();                                         // SC 2.4.3: stay put
    announce(`Could not load items under ${rowName(row)}. Press Right Arrow to retry.`, 'assertive');
  }
}

The requestAnimationFrame before announcing gives VoiceOver the frame it needs after aria-busy is cleared; without it, Safari can swallow the message because the subtree was still flagged busy when the region changed.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT-specific deviations
Right Arrow on collapsed row “expanded” NVDA reads the full row again with “expanded”
Down Arrow during load “Loading…, level 2” JAWS may say “row 5 of 5” if aria-rowcount is absent
Data arrives “14 items loaded under Finance.” VoiceOver drops it if fired while aria-busy is still set
Down Arrow after load “Payroll, 1 of 14, level 2” TalkBack reads level only on first entry to a level
Load fails “Could not load items under Finance. Press Right Arrow to retry.” Assertive: interrupts current speech on all readers
Right Arrow again Retries the fetch Must be debounced so a double press does not fire two requests
Failure handling, bad and good Comparison of a lazy expansion failure that leaves the row expanded and empty against one that collapses, keeps focus and announces a retry path. Failure handling, bad and good✗ Silent failureRow stays aria-expanded="true" with nochildrenPlaceholder removed, nothing announcedLooks identical to an empty folderFocus may land on the removed placeholder✓ Honest failureRow returns to aria-expanded="false"Focus stays on the parent rowAssertive message names the row and the retrykeyRetry uses the same key as expand
A silent failure looks exactly like an empty folder — the user cannot tell the difference.

Integration context

Permalink to "Integration context"

Children that arrive with a known total larger than the page you fetched — “first 50 of 1,200” — should carry the real aria-setsize so the position announcement is honest, and a final “Load more” row at the same level. That is the same count honesty described for virtual lists in aria-setsize and aria-posinset in virtual lists.

The announcement function should be the page’s shared status region, not a new region per treegrid, so that a load message and, say, a filter count cannot collide.

Guarding one row's expansion Flow of the guards around a lazy expansion: an in-flight check, a fetch with an abort controller, a collapse that aborts, and the final render. Guarding one row's expansionKeypressRight Arrow,possiblyauto-repeatingIn-flight?ignore if a requestis runningFetchwith anAbortControllerper rowCollapsedearly?Left Arrow aborts;drop the resultRenderrows, levels,message
Three small guards stop auto-repeat, early collapse and late responses from corrupting the tree.

Gotchas

Permalink to "Gotchas"

Focus inside the placeholder. If the placeholder row is focusable and the user arrows into it, replacing it destroys focus. Keep placeholders out of the roving tabindex set.

Double requests. Holding Right Arrow auto-repeats. Guard with an in-flight flag per row.

Collapsing mid-load. If the user presses Left Arrow before the response arrives, cancel the request with an AbortController and drop the result, rather than inserting children under a collapsed parent.

Design system notes

Permalink to "Design system notes"

A treegrid component that supports lazy children should expose the loading and failure states as first-class props rather than leaving them to each product team: a loadChildren callback that returns a promise, a placeholder label that can be localised, and a retry key documented in the component’s keyboard help. When the component owns the sequence — expanded, placeholder, rows, message — every product gets the same audible behaviour, and the failure path is tested once instead of never.

Keep the announcement text configurable but the timing fixed. Product teams tend to change wording; they should not be able to move the message before the rows exist.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
What should aria-setsize be when only the first page of children is loaded?

The real total number of children, if the server reports it, so that “3 of 1,200” is honest. Add a “Load more” row at the same level for fetching the next page, and update positions as rows arrive.

When should aria-expanded become true for a lazily loaded row?

Immediately on the user’s request, before the data arrives. That acknowledges the keypress. Show a loading placeholder as a child row, then replace it with the real children, and set aria-expanded back to false only if the load fails.

Is aria-busy enough to tell users children are loading?

No. Most screen readers do not announce aria-busy at all. Use a visible and readable “Loading…” placeholder row, and a polite status message when the children are in place. Treat aria-busy as a hint that can help VoiceOver batch the changes.

How should a failed child load be announced?

Assertively, naming the row and the retry action, with the row collapsed again and focus kept on it. A silent failure looks identical to a genuinely empty branch.

Permalink to "Related"

← Back to Expandable Rows & Nested Data