Lazy-Loading Tree Nodes with aria-busy

Permalink to "Lazy-Loading Tree Nodes with aria-busy"

File browsers, org charts and large category trees load children on demand: expanding a folder triggers a request, and its contents appear a moment later. The accessibility problems are timing and truth. If the parent says “expanded” but nothing appears, users press Down and land on the next sibling, confused. If focus jumps into children when they arrive, users lose their place. If the folder turns out to be empty, a disappearing chevron says nothing to a screen reader.

This guide belongs to tree views & hierarchical navigation.

Spec reference

Permalink to "Spec reference"
  • aria-expanded — set on any node that may have children, even before loading; a node with no aria-expanded is a leaf.
  • aria-busy="true" on the child group while loading — assistive technology may defer reading until it is false.
  • aria-setsize / aria-posinset — when only some children are loaded (paged branches), give the full size so “3 of 240” is correct.
  • SC 4.1.2 (A) — expanded and leaf states must be truthful.
  • SC 4.1.3 Status Messages (AA) — “Loading Contracts” and “Contracts: 14 items” or “Contracts is empty” are status messages.
  • SC 2.4.3 Focus Order (A) — focus remains on the parent until the user moves.
Expanding a folder whose children load Sequence among the parent node, its child group and the status region: Right arrow expands; the group shows a busy loading item; children arrive; the group is no longer busy and the status reports the count; focus stays on the parent. Expanding a folder whose children loadParent nodeChild groupStatusRight: aria-expanded="true"aria-busy="true"; "Loading…" item"Loading Contracts" (if slow)children arrive; busy false"Contracts: 14 items"
Focus does not move; the user presses Down when ready.

When to use — and when not to

Permalink to "When to use — and when not to"

Lazy-load children whenever branches are large or numerous enough that loading the full tree up front is slow. Mark every potential parent with aria-expanded="false" from the start if you know it can have children; if you do not know, treat every folder as a potential parent and handle the empty case.

Do not move focus into children automatically when they arrive. Do not remove aria-expanded from a parent that turned out to be empty without saying so.

The misapplication: setting aria-expanded="true" immediately with an empty group and no loading item. Users who press Down quickly land on the next sibling, believing the folder is empty.

Silent lazy loading versus announced lazy loading Comparison: silent loading leaves an expanded parent with nothing under it and a vanishing chevron for empty folders; announced loading shows a loading item, reports counts and states emptiness. Silent lazy loading versus announced lazy loading✗ Silent loadingExpanded with no children for a momentDown skips to the next siblingEmpty folder: chevron disappearsFailure: nothing happensFocus jumps into children✓ Announced loadingLoading item under the parentDown reaches "Loading…""Contracts is empty"Error item with RetryFocus stays on parent

Annotated code example

Permalink to "Annotated code example"
async function expand(item) {
  item.setAttribute('aria-expanded', 'true');
  const group = ensureGroup(item);
  if (group.dataset.loaded) return;
  group.setAttribute('aria-busy', 'true');
  group.innerHTML = '<li role="treeitem" aria-disabled="true" tabindex="-1">Loading…</li>'; // reachable placeholder
  const slow = setTimeout(() => { status.textContent = `Loading ${label(item)}`; }, 500);
  try {
    const { children, total } = await loadChildren(item.dataset.id);
    group.innerHTML = '';
    if (total === 0) {
      group.innerHTML = '<li role="treeitem" aria-disabled="true" tabindex="-1">Empty folder</li>';
      status.textContent = `${label(item)} is empty`;
    } else {
      children.forEach((c, i) => group.append(treeItem(c, { posinset: i + 1, setsize: total }))); // full set size
      status.textContent = `${label(item)}: ${total} items`;
    }
    group.dataset.loaded = '1';
  } catch {
    group.innerHTML = '<li role="treeitem" tabindex="-1">Could not load. <button type="button">Retry</button></li>';
    status.textContent = `Could not load ${label(item)}`;
  } finally {
    clearTimeout(slow); group.setAttribute('aria-busy', 'false');   // focus stayed on item (SC 2.4.3)
  }
}

Interactive buttons inside treeitems are generally discouraged; for the retry case, an alternative is to make the error item itself activatable with Enter (“Could not load. Press Enter to retry”).

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Right on “Contracts” “expanded” —
Slow load “Loading Contracts” —
Load completes “Contracts: 14 items” —
Down after load “Lund & Co contract.pdf, level 3, 1 of 14” —
Empty folder “Contracts is empty”; Down reaches “Empty folder” —
Failure “Could not load Contracts” —
Branch outcomes and what users hear Matrix of outcomes — fast load, slow load, empty branch, failed load, paged branch — against the visible child content, the status message and set size. Branch outcomes and what users hearChild contentStatus messageSet sizeFast loadChildrenCountFullSlow loadLoading item, then childrenLoading, then countFullEmpty"Empty folder" item"is empty"1 placeholderFailedError item"Could not load"NonePaged branchFirst page plus "Load more"Count of totalTotal via setsize
Every outcome produces something under the parent and a short message.

Integration context

Permalink to "Integration context"

The tree itself follows the tree role for category and file browsers. The table counterpart is lazy loading child rows in a treegrid. Large branches loaded in pages use the same set-size technique as aria-setsize and aria-posinset in virtual lists.

Gotchas

Permalink to "Gotchas"

Collapsing during load. If the user collapses the parent before children arrive, keep the result cached but do not announce it.

Type-ahead over unloaded branches. Type-ahead only finds loaded, visible nodes; for deep search, provide a search field.

Huge branches. A folder with 20,000 files should not render them all. Load a page, set aria-setsize to the total, and add a “Load 100 more” item.

Framework notes

Permalink to "Framework notes"

React Aria Tree supports async loading with a loading indicator item in recent versions; ensure the indicator is announced. Angular CDK trees use a data source you control; render a loading node while fetching. MUI X RichTreeView supports lazy loading via dataSource in newer versions; test announcements, which you will likely add yourself.

// Angular: loading node in a flat tree data source
this.dataChange.next([...before, { name: 'Loading…', level: node.level + 1, isLoading: true }, ...after]);

Worked example

Permalink to "Worked example"

A document management tree expanded folders with no visible or audible loading state; on slow connections, a JAWS user pressed Down after expanding “Contracts” and landed on “Invoices”, concluding Contracts was empty and filing a ticket. With a loading item, a 500 ms threshold for announcing “Loading Contracts”, and “Contracts: 14 items” on completion, the confusion disappeared. Empty folders now say so rather than silently losing their chevron.

Common mistakes

Permalink to "Common mistakes"
  • Removing the chevron when a folder turns out empty — the node changes from parent to leaf silently.
  • Re-fetching on every expand. Cache loaded children; collapsing and expanding should be instant.
  • Spinner icons as the only loading indicator — hidden from screen readers and meaningless without text.
  • Announcing every branch load during “expand all”. Summarise instead: “Expanded 12 folders, 340 items.”

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Give the tree a loadChildren(node) contract and let the component handle busy state, placeholders, empty and error items, thresholds and messages. Products then implement the fetch, not the accessibility.

FAQ

Permalink to "FAQ"
Should aria-expanded be true while loading?

Yes — the user asked to expand, and a loading item appears under the parent. That is truthful.

Does aria-busy stop screen readers announcing changes?

It hints that the region is updating; readers may defer reading it. Do not rely on it alone; the status message and placeholder carry the information.

Should focus move to the first child when loaded?

No. The user may want to expand several folders in a row. Let them press Down.

How do I represent "unknown whether it has children"?

Treat it as a parent (aria-expanded false) and handle the empty result explicitly.

Can the loading item be focusable?

It can be reachable as a disabled treeitem so users who press Down hear “Loading…” rather than skipping past.

What about very slow branches?

After several seconds, update the status (“Still loading Contracts”) once, and allow cancel by collapsing.

Permalink to "Related"

← Back to Tree Views & Hierarchical Navigation