UX Design

Accessible Loading States and Progress Indicators: Communicating Wait Times

By EZUD Published · Updated

Accessible Loading States and Progress Indicators: Communicating Wait Times

Loading states are the gaps between user action and system response — form submissions, page transitions, data fetches, file uploads. Sighted users see spinners, skeleton screens, and progress bars. Screen reader users hear nothing unless loading states are explicitly announced. When the system appears unresponsive to assistive technology, users cannot tell whether their action worked, whether they should wait, or whether something went wrong.

Types of Loading Indicators

Indeterminate Spinners

Spinning animations that indicate something is loading without specifying how long. Use when the duration is unknown.

<div role="status" aria-live="polite">
  <span class="spinner" aria-hidden="true"></span>
  <span>Loading results...</span>
</div>

The role="status" with aria-live="polite" announces “Loading results…” to screen readers when the element appears. The spinner SVG or CSS animation is hidden from screen readers with aria-hidden="true" since it conveys no information to non-visual users.

Determinate Progress Bars

Progress bars that show completion percentage. Use for file uploads, multi-step processes, and any operation where progress can be measured.

<div role="progressbar"
     aria-valuenow="45"
     aria-valuemin="0"
     aria-valuemax="100"
     aria-label="Uploading profile photo"
     aria-valuetext="45 percent complete">
  <div class="progress-fill" style="width: 45%"></div>
</div>

Key attributes:

  • role="progressbar": Identifies the element as a progress indicator
  • aria-valuenow: Current progress value
  • aria-valuemin / aria-valuemax: Range bounds
  • aria-label: Describes what is progressing
  • aria-valuetext: Human-readable progress description (screen readers announce this instead of the raw number)

Skeleton Screens

Placeholder layouts that mimic the page structure before content loads. Skeleton screens provide visual feedback that content is coming, but they are meaningless to screen readers.

Make skeleton screens accessible by:

  1. Adding a live region that announces the loading state: “Loading dashboard content…”
  2. Hiding skeleton placeholder elements from screen readers with aria-hidden="true"
  3. Announcing when loading is complete: “Dashboard content loaded”
<main aria-busy="true">
  <div aria-hidden="true" class="skeleton">
    <!-- Visual placeholder blocks -->
  </div>
  <div role="status" aria-live="polite" class="visually-hidden">
    Loading dashboard content...
  </div>
</main>

When content loads, remove aria-busy, remove skeleton elements, inject real content, and update the status region.

Inline Loading (Button States)

When a button triggers an async action, communicate the loading state:

<!-- Before click -->
<button>Save Changes</button>

<!-- During save -->
<button aria-disabled="true" aria-busy="true">
  <span class="spinner" aria-hidden="true"></span>
  Saving...
</button>

<!-- After save -->
<button>Save Changes</button>
<div role="status" aria-live="polite">Changes saved successfully.</div>

The button text changes to “Saving…” providing both visual and screen reader feedback. The aria-disabled prevents double-submission. The aria-busy communicates the processing state. After completion, a status message confirms the result through accessible notification patterns.

The aria-busy Attribute

aria-busy="true" on a container tells assistive technologies that the region is being updated and they should wait before presenting its content. This prevents screen readers from announcing partial or placeholder content during loading.

Apply aria-busy to the container being updated, and remove it when the update is complete:

// Start loading
container.setAttribute('aria-busy', 'true');
statusRegion.textContent = 'Loading...';

// Fetch data...
const data = await fetchData();

// Update content
container.innerHTML = renderContent(data);
container.removeAttribute('aria-busy');
statusRegion.textContent = 'Content loaded.';

Progress Announcements for Long Operations

For operations lasting more than a few seconds, provide periodic progress updates:

function updateProgress(percent) {
  progressBar.setAttribute('aria-valuenow', percent);
  progressBar.setAttribute('aria-valuetext', `${percent} percent complete`);

  // Announce milestones to avoid excessive announcements
  if (percent % 25 === 0) {
    statusRegion.textContent = `Upload ${percent}% complete`;
  }
}

Announcing every 1% change would overwhelm screen reader users. Announce at meaningful milestones — 25%, 50%, 75%, complete — or at regular intervals no more frequently than every 10 seconds.

Error States After Loading

When loading fails, the error state must be clearly communicated:

<div role="alert">
  Failed to load search results. <button>Try again</button>
</div>

The role="alert" with assertive behavior ensures screen reader users immediately learn about the failure. Include a retry action so users can recover without navigating away.

Timing and Expectations

When to Show Loading Indicators

  • < 100ms: No indicator needed. The response feels instantaneous.
  • 100ms - 1 second: Show a subtle indicator (spinner, button state change) to confirm the action was received.
  • 1 - 10 seconds: Show a progress indicator. If determinate, show a progress bar. If indeterminate, show a spinner with descriptive text.
  • > 10 seconds: Show a progress bar with estimated time remaining. Consider whether the operation can be backgrounded with a notification on completion.

Reduced Motion

Loading spinners and animated progress bars should respect prefers-reduced-motion. Replace spinning animations with a static “loading” text or a pulsing opacity fade (which is generally safe for motion-sensitive users):

@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
  }
  .spinner::after {
    content: '...';
    animation: pulse 1.5s ease-in-out infinite;
  }
}

Page-Level Loading

For full-page loads (route transitions in single-page applications):

  1. Announce navigation: “Loading Products page…”
  2. Show a progress indicator (top-of-page bar, skeleton screen, or spinner)
  3. When loaded, announce completion: “Products page loaded” and move focus to the main content heading or the <main> element

This is especially important in SPAs where the browser’s native loading indicators do not trigger.

Testing Loading States

  1. Trigger every loading state with a screen reader active. Verify each is announced.
  2. Simulate slow networks (Chrome DevTools throttling) to experience long loading times and verify progress communication.
  3. Simulate failures and verify error announcements.
  4. Test with prefers-reduced-motion enabled. Verify spinners and animations are reduced.
  5. Keyboard test: Verify focus is not lost during loading state transitions.
  6. Apply testing methodology for systematic coverage.

Key Takeaways

Every loading state needs a screen reader announcement — not just a visual indicator. Use role="status" for polite loading announcements, role="progressbar" for measurable progress, and role="alert" for failures. Apply aria-busy to containers being updated. Announce milestones for long operations rather than continuous updates. Replace spinning animations with text alternatives when reduced motion is preferred. The gap between action and response is where trust is built or broken — accessible loading states keep every user informed.

Sources