Lazy-Loaded Tab Panels with Data

Permalink to "Lazy-Loaded Tab Panels with Data"

Record pages in data apps often have tabs — Overview, Invoices, Activity, Files — and each tab’s content comes from a different API call. Loading everything up front is slow; loading on demand is the norm. With automatic tab activation, a keyboard user arrowing from Overview to Files triggers three fetches they did not want, and each panel flashes a spinner. With nothing announced, a screen reader user who activates “Invoices” hears the tab and then silence while data loads.

This guide belongs to tabs & view switchers for data views.

Spec reference

Permalink to "Spec reference"
  • WAI-ARIA tabs pattern — manual activation — arrow keys move focus between tabs; Enter or Space activates. Recommended when displaying a panel has a noticeable cost.
  • aria-busy="true" on the panel while it loads — tells assistive technology the region is being updated.
  • SC 4.1.3 Status Messages (AA) — “Loading invoices” and “12 invoices” are status messages.
  • SC 2.2.1 Timing Adjustable (A) and SC 3.2.1 On Focus (A) — focus on a tab must not trigger costly changes the user did not request; manual activation addresses this.
Manual activation with a lazy panel Sequence among tablist, panel and status: arrows move between tabs without loading; Enter activates Invoices; the panel shows a busy loading state; data arrives; aria-busy clears and a short status reports the count. Manual activation with a lazy panelTablistPanelStatusRight ×2: focus moves, nothing loadsEnter on "Invoices": show panel,aria-busy="true""Loading invoices"data arrives; render"12 invoices"
Fetch happens on Enter, not on arrow keys.

When to use — and when not to

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

Use lazy tab panels when each panel is expensive — a separate request, a heavy chart, a large table. Use manual activation for them.

Keep automatic activation when panels are already loaded or instant; it is faster for keyboard users. Do not mix: within one tablist, use one activation mode.

The misapplication: automatic activation with lazy loading. Arrowing across four tabs fires four requests, the first three are wasted, and the screen reader announces three loading states the user never wanted.

Requests fired while arrowing across four tabs Bar chart comparing the number of data requests when a keyboard user arrows from the first to the last of four tabs: automatic activation fires 3 unnecessary requests plus the one wanted, manual activation fires only the 1 wanted. Requests fired while arrowing across four tabsAutomatic activationthree panels loaded and discardedManual activationonly the chosen panel loads
Manual activation avoids wasted loads and noisy announcements.

Annotated code example

Permalink to "Annotated code example"
<div role="tablist" aria-label="Customer record">
  <button role="tab" id="t-overview" aria-selected="true" aria-controls="p-overview" tabindex="0">Overview</button>
  <button role="tab" id="t-invoices" aria-selected="false" aria-controls="p-invoices" tabindex="-1">Invoices</button>
</div>
<section role="tabpanel" id="p-invoices" aria-labelledby="t-invoices" hidden></section>
<p id="tab-status" role="status" class="visually-hidden"></p>
const cache = new Map();
tablist.addEventListener('keydown', (e) => {
  const i = tabs.indexOf(document.activeElement);
  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {          // move only (manual activation)
    e.preventDefault();
    const n = tabs[(i + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length];
    tabs.forEach(t => (t.tabIndex = t === n ? 0 : -1)); n.focus();
  }
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(tabs[i]); }
});

async function activate(tab) {
  select(tab);                                                     // aria-selected, show panel
  const panel = document.getElementById(tab.getAttribute('aria-controls'));
  if (cache.has(tab.id)) return;                                   // instant on return
  panel.setAttribute('aria-busy', 'true');
  panel.innerHTML = `<p>Loading ${tab.textContent.toLowerCase()}…</p>`;   // visible loading text
  const slow = setTimeout(() => { status.textContent = `Loading ${tab.textContent.toLowerCase()}`; }, 500);
  try {
    const data = await load(tab.id);
    renderPanel(panel, data); cache.set(tab.id, data);
    status.textContent = data.summary;                             // e.g. "12 invoices"
  } catch {
    panel.innerHTML = `<p>Could not load ${tab.textContent.toLowerCase()}.</p><button type="button">Try again</button>`;
    status.textContent = `Could not load ${tab.textContent.toLowerCase()}.`;
  } finally {
    clearTimeout(slow); panel.setAttribute('aria-busy', 'false');
  }
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT deviations
Right arrow “Invoices, tab, not selected, 2 of 4” —
Enter “Invoices, tab, selected” then (if slow) “Loading invoices” then “12 invoices” —
Tab into panel while busy Loading text Some readers skip busy regions — the visible loading text helps
Return to a loaded tab Tab name; panel instant No status message — none needed
Failure “Could not load invoices.” Retry in panel —
Panel states and what users hear Matrix of panel states — first load fast, first load slow, cached, failed — against the visible content, aria-busy and the status message. Panel states and what users hearVisible contentaria-busyStatus messageFirst load, fastDataBriefly trueSummaryFirst load, slowLoading text, then dataTrue while loading"Loading…", then summaryCachedData immediatelyFalseNoneFailedError and Try againFalse"Could not load…"
Messages only when something is worth hearing.

Integration context

Permalink to "Integration context"

Loading states follow announcing async loading states in data tables and using aria-busy during progressive table loads. Failures inside panels use the patterns in announcing data load errors in grids. The tabs themselves follow the tabs pattern for switching table and chart views.

Gotchas

Permalink to "Gotchas"

aria-busy forever. If the request throws before finally, the panel stays busy and some readers refuse to read it. Always clear in finally.

Stale caches. Cached panels can show outdated data after edits elsewhere. Invalidate on relevant changes, and show a “last updated” time in panels that cache for long.

Skeletons. Skeleton placeholders inside a busy panel should be hidden from assistive technology; the visible “Loading…” text carries the message.

Framework notes

Permalink to "Framework notes"

Radix Tabs and React Aria Tabs support manual activation (activationMode="manual", keyboardActivation="manual"). Combine with TanStack Query for caching — enabled: activeTab === 'invoices' loads on activation and caches automatically. Angular Material mat-tab-group supports lazy content via <ng-template matTabContent>; its keyboard activation is automatic on focus change unless you handle it — test that arrowing does not trigger loads.

const invoices = useQuery({ queryKey: ['invoices', id], queryFn: loadInvoices, enabled: tab === 'invoices' });
<Tabs.Root value={tab} onValueChange={setTab} activationMode="manual">…</Tabs.Root>

Worked example

Permalink to "Worked example"

A CRM record page had seven tabs with automatic activation and lazy loading. A keyboard user moving from “Overview” to “Files” triggered five API calls and heard five loading messages. Switching to manual activation, adding a 500 ms threshold before announcing “Loading”, and caching loaded panels cut the calls to one and the messages to one (“8 files”). Mouse users noticed nothing different.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Expose activation="auto" | "manual" on the tabs component, defaulting to manual when panels declare a loader. Provide a LazyPanel that handles busy state, the loading threshold, error and retry, and caching hooks.

FAQ

Permalink to "FAQ"
Is manual activation harder for keyboard users?

It adds one key press per switch. In exchange, users can explore tab names freely without triggering loads. For expensive panels it is the better trade.

Should focus move into the panel after loading?

No. Users may want to try another tab. They can press Tab to enter the panel when ready.

How long before announcing "Loading"?

About half a second. Faster loads should go straight to the summary.

What should the summary say?

A short count or headline: “12 invoices”, “No files”, “Activity for the last 30 days”. Not the whole panel.

Should all tabs prefetch on idle?

Prefetching the next likely tab is fine, as long as it never changes what is announced or focused.

What if the user activates another tab while one is loading?

Cancel or ignore the first request’s result, switch immediately, and announce only the panel the user is now on. A late message about a panel the user has left is confusing.

Should a retry button inside a failed panel move focus?

Keep focus on the retry button while it retries, as in other retry patterns, and announce the outcome. Do not move focus out of the panel on success; the user is already there.

Permalink to "Related"

← Back to Tabs & View Switchers for Data Views