Accessibility pattern · Feedback and status

Loading state

While something loads, the button that started it stays focusable but unavailable, the region being filled is marked aria-busy, and the spinner has words beside it. A polite status says when loading starts, when it runs long and how many results arrived, so a screen reader user is never left in silence.

WCAG criteria
5
Keyboard rules
3
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Store details

Recent orders

Indigo Loom, today

  • Meera Iyer #4127 · ₹2,450 Shipped
  • Rohan Das #4126 · ₹899 Packed
  • Fatima Shaikh #4125 · ₹5,120 Delivered

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Enter or SpaceOn Save changes, starts saving. The button says Saving… and stays focused; pressing it again does nothing until the save is done.
Enter or SpaceOn Refresh, reloads the orders. Refresh stays focused but unavailable while they load.
TabMoves between the field and the buttons as usual; nothing is taken out of the Tab order while loading.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Save changes is pressedChanges saved, when the save is done (the button reads Saving… in the meantime)
Refresh is pressedLoading orders…
Loading runs past three secondsStill loading — this is taking longer than usual.
The orders arrive6 orders loaded
Focus is on Refresh while it loadsRefresh, button, unavailable

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-loading" data-ap-loading>
  <section class="ap-loading__card" aria-labelledby="loading-store-h">
    <h3 class="ap-loading__h" id="loading-store-h">Store details</h3>
    <div class="ap-field">
      <label class="ap-label" for="loading-store-name">Store name</label>
      <input class="ap-input" id="loading-store-name" name="store" autocomplete="organization" value="Indigo Loom" />
    </div>
    <div class="ap-loading__foot">
      <button type="button" class="ap-btn ap-btn--primary ap-loading__save" data-ap-save>
        <span class="ap-loading__face" data-ap-idle>Save changes</span>
        <span class="ap-loading__face" aria-hidden="true" data-ap-busy><span class="ap-loading__spin"></span>Saving…</span>
      </button>
      <p class="ap-loading__note" role="status" data-ap-save-say></p>
    </div>
  </section>

  <section class="ap-loading__card" aria-labelledby="loading-orders-h">
    <div class="ap-loading__top">
      <div>
        <h3 class="ap-loading__h" id="loading-orders-h">Recent orders</h3>
        <p class="ap-loading__sub">Indigo Loom, today</p>
      </div>
      <button type="button" class="ap-btn ap-loading__refresh" data-ap-refresh>
        <svg class="ap-btn__icon ap-loading__turn" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 11a8 8 0 0 0-14.3-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.3 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
        Refresh
      </button>
    </div>
    <div class="ap-loading__area">
      <ul class="ap-loading__list" aria-labelledby="loading-orders-h" aria-busy="false" data-ap-list>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Meera Iyer</span>
          <span class="ap-loading__meta">#4127 · ₹2,450</span>
        </span>
        <span class="ap-loading__state">Shipped</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Rohan Das</span>
          <span class="ap-loading__meta">#4126 · ₹899</span>
        </span>
        <span class="ap-loading__state">Packed</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Fatima Shaikh</span>
          <span class="ap-loading__meta">#4125 · ₹5,120</span>
        </span>
        <span class="ap-loading__state">Delivered</span>
      </li>
      </ul>
      <div class="ap-loading__cover" aria-hidden="true" hidden data-ap-cover>
        <span class="ap-loading__spinner"></span>
        <p class="ap-loading__what">Loading orders…</p>
        <p class="ap-loading__slow" hidden data-ap-slow>Still loading — this is taking longer than usual.</p>
      </div>
    </div>
    <p class="ap-loading__sr" role="status" data-ap-say></p>
  </section>

  <template data-ap-orders>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Kavya Menon</span>
          <span class="ap-loading__meta">#4130 · ₹1,340</span>
        </span>
        <span class="ap-loading__state">New</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Arjun Singh</span>
          <span class="ap-loading__meta">#4129 · ₹3,075</span>
        </span>
        <span class="ap-loading__state">Confirmed</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Neha Kulkarni</span>
          <span class="ap-loading__meta">#4128 · ₹640</span>
        </span>
        <span class="ap-loading__state">New</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Meera Iyer</span>
          <span class="ap-loading__meta">#4127 · ₹2,450</span>
        </span>
        <span class="ap-loading__state">Delivered</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Rohan Das</span>
          <span class="ap-loading__meta">#4126 · ₹899</span>
        </span>
        <span class="ap-loading__state">Shipped</span>
      </li>
      <li class="ap-loading__row">
        <span class="ap-loading__who">
          <span class="ap-loading__name">Fatima Shaikh</span>
          <span class="ap-loading__meta">#4125 · ₹5,120</span>
        </span>
        <span class="ap-loading__state">Delivered</span>
      </li>
  </template>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The order list is named by its heading and marked aria-busy while its contents are replaced, so the loading state is in the markup, not only on screen.

  • 2.3.3 Animation from Interactions Level AAA

    The spinner and the turning icon stop moving when the system asks for reduced motion; the words beside them carry on saying what is happening.

  • 2.4.3 Focus Order Level A

    Neither button loses focus while it is busy: aria-disabled keeps it in place, where the disabled attribute would throw focus away.

  • 4.1.2 Name, Role, Value Level A

    The busy button's name changes to Saving… and it reports itself unavailable; the spinner inside it is hidden from assistive technology.

  • 4.1.3 Status Messages Level AA

    Loading, the slow-down note and the result are announced from a polite status, without focus moving.

Usage

When to use it

Use it

  • Anything that takes long enough to notice, roughly over half a second: saving, refreshing, searching, loading a panel.
  • Content replaced in place, where people need to know the old content is about to change.

Use something else

  • Loads under a few hundred milliseconds: show nothing, or delay the spinner, so it does not flash.
  • Long tasks whose size is known: a progress bar tells people how far along it is.
  • Whole pages loading for the first time: a skeleton keeps the layout steady while content arrives.

Common failures

How it usually goes wrong

  • A spinner and nothing else

    A spinning circle says nothing to a screen reader and little to many others. Here it has words beside it, and a status says the same.

  • disabled on the busy button

    The disabled attribute takes the button out of the Tab order and drops focus to the top of the page. aria-disabled keeps it focusable; the script ignores presses while busy.

  • Silence when it runs long

    A wait with no end in sight feels broken. After three seconds a note says it is still loading, on screen and to screen readers.

  • Announcing every row as it arrives

    Content streamed into a live region reads each item aloud. The list is not live; one message says how many arrived.

  • Old content that looks current

    Leaving stale rows on screen without a sign invites people to act on them. The list is covered and marked busy until the new rows are in.

  • A spinner that never stops

    Endless rotation can make people with vestibular disorders unwell. Under reduced motion the spinner stands still and the words stay.

Notes

Building it

  • aria-busy="true" tells assistive technology the region is being updated, and some screen readers hold back reading it until it is false. Support varies, so the status message carries the news.
  • The status element is in the HTML from the start, empty. Live regions announce changes to content that is already in the accessibility tree; a region created with its message, or unhidden at the same moment, is often missed.
  • The busy label and the idle label share one grid cell, so the button keeps its width; the one not in use is hidden with aria-hidden and visibility, so the name is always the visible words.
  • The overlay over the list is aria-hidden: it is a picture of the busy state, which aria-busy and the status message already expose.
  • The Slow network option makes both waits longer, so the still loading note appears.

Sources: WAI-ARIA 1.2: aria-busy · Understanding SC 4.1.3: Status Messages · Understanding SC 2.3.3: Animation from Interactions

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction