core accessibility principles for modern frameworks
Accessible Loading Skeletons and Spinners
Loading indicators are deceptively hard to get right. A spinning circle or a shimmering skeleton looks finished, but it usually communicates nothing to a screen reader and frequently animates in ways that distress motion-sensitive users. The fix is to separate two concerns the visuals conflate: the announcement that work is happening, which belongs to assistive technology, and the decoration that suggests it visually, which should be hidden from assistive technology and silenced under reduced motion. This guide implements both, building on Reduced Motion & Animation Accessibility.
Prerequisites
You should be comfortable rendering conditional UI in React and writing CSS keyframes. Familiarity with ARIA live regions helps but is not required—the relevant attributes are explained inline. The patterns are framework-agnostic in spirit; the React and CSS here translate directly to Vue, Svelte, or Angular.
The governing success criterion is 4.1.3 Status Messages (Level AA): a change of status—such as content beginning to load—must be programmatically exposed to assistive technology without moving focus. Loading states are the textbook example, and a role="status" live region is the textbook implementation.
Announcing the Loading State
A screen-reader user perceives no spinner. If the only signal that data is loading is a rotating SVG, that user is left in silence wondering whether their action registered. The announcement must therefore live in the accessibility tree, delivered through a polite live region with sr-only text.
role="status" carries an implicit aria-live="polite", so the screen reader queues the message after the current utterance rather than interrupting. Pair it with aria-busy="true" on the region that is being populated, which tells assistive technology the area is mid-update and its contents are not yet stable. Flip aria-busy to false and clear the status text when the data arrives.
'use client';
import { useReducedMotion } from './useReducedMotion';
export function LoadingPanel({
loading,
children,
}: {
loading: boolean;
children: React.ReactNode;
}) {
const reduced = useReducedMotion();
return (
// aria-busy tells AT this region is updating; it flips false when done. (4.1.3)
<section aria-busy={loading}>
{loading ? (
<>
{/* Decorative motion — hidden from screen readers entirely. */}
<span
className={reduced ? 'spinner spinner--static' : 'spinner'}
aria-hidden="true"
/>
{/* The real announcement: polite, off-screen, focus-preserving. */}
<p role="status" className="sr-only">
Loading content, please wait.
</p>
</>
) : (
children
)}
</section>
);
}
Keep the announcement concise and singular. A live region that re-renders "Loading…" on every keystroke or poll floods the speech queue; announce the state once when it begins and once when it resolves. The companion guide Dynamic Content & State Announcements covers the broader discipline of keeping live regions quiet enough to be useful.
The sr-only class is the standard visually-hidden recipe—present in the DOM and the accessibility tree, but clipped out of view:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
Marking the Visual Indicator Decorative
The spinner or skeleton is pure decoration once the live region carries the meaning. Leaving it exposed causes a screen reader to either announce nothing useful (an unlabeled SVG) or, worse, to double-report alongside the live region. Hide it with aria-hidden="true" so only the role="status" text reaches assistive technology.
This division of labor is the whole pattern: one element speaks, the other is seen. Never label the spinner itself with aria-label="Loading" and also render a status region—that produces two announcements for one event. Pick the live region; it is the one that respects politeness and does not steal focus.
<!-- Decorative: seen, never announced -->
<svg class="spinner" aria-hidden="true" viewBox="0 0 24 24" focusable="false">
<circle cx="12" cy="12" r="10" />
</svg>
<!-- Announced: heard, never seen -->
<p role="status" class="sr-only">Loading results</p>
Adding focusable="false" to inline SVG keeps legacy versions of Internet Explorer and some screen-reader/browser pairings from placing the decorative graphic in the tab order—a small but worthwhile defense.
Stopping Shimmer Under Reduced Motion
Skeleton shimmer is a gradient sweeping horizontally across placeholder blocks—spatial, looping motion that falls squarely under 2.3.3 Animation from Interactions and the spirit of 2.2.2 Pause, Stop, Hide. Under prefers-reduced-motion: reduce it must stop. The accessible substitute is a static muted fill or, at most, a gentle in-place opacity pulse that moves nothing through space.
Following the motion-safe default, the shimmer animation lives only inside a no-preference query, so reduced-motion users get the static skeleton automatically:
.skeleton {
background: var(--surface);
border-radius: 6px;
}
/* Sweep only when the user has not asked to reduce motion. */
@media (prefers-reduced-motion: no-preference) {
.skeleton {
background: linear-gradient(
90deg,
var(--surface) 25%,
var(--primary-soft) 50%,
var(--surface) 75%
);
background-size: 200% 100%;
animation: skeleton-sweep 1.4s ease-in-out infinite;
}
}
@keyframes skeleton-sweep {
to { background-position: -200% 0; }
}
/* The spinner: rotation is acceptable feedback, but slow it and avoid */
/* large sweeps under reduce. */
.spinner { animation: spin 0.8s linear infinite; }
.spinner--static { animation: none; opacity: 0.7; } /* JS-gated reduced variant */
@keyframes spin {
to { transform: rotate(360deg); }
}
A spinner's rotation is closer to essential feedback than a shimmer sweep, but it is still motion. Under reduced motion, prefer slowing it dramatically or swapping to a non-rotating "working" indicator (a pulsing dot, a determinate progress bar) over a fast continuous spin. The aria-busy plus role="status" announcement already conveys the state, so a reduced or static visual loses nothing semantically.
Not Trapping Focus While Loading
A loading state is transient, and focus must survive it. Two failure modes are common. First, moving focus to the spinner: calling .focus() on a loading indicator yanks the keyboard user out of context, and when loading finishes the indicator unmounts, leaving focus on a detached node. Never focus a decorative, soon-to-disappear element. Second, trapping focus inside the loading region as if it were a modal—loading is not a modal interaction and the user must remain free to navigate or cancel.
Let focus rest where the user left it. The role="status" region announces progress without moving the cursor, which is precisely why aria-live="polite" is the right tool—it informs without hijacking. When content finishes loading, decide focus deliberately: if the load was a route change or a user-requested result set, move focus to the new content's heading; if it was a background refresh, leave focus untouched. This coordinates with broader focus discipline in Focus Management Strategies for SPAs.
How to Verify
1. Screen reader announcement. With NVDA (Firefox), VoiceOver (Safari), or Narrator, trigger the loading state. You should hear the role="status" text ("Loading content…") announced once, politely, without focus moving. When data arrives, confirm the busy state clears and the spinner text is gone—no lingering "Loading" in the buffer.
2. Accessibility tree inspection. In Chrome DevTools, open the Accessibility pane and select the loading region. Confirm the spinner/SVG is absent from the tree (because of aria-hidden="true") and the status node is present with role "status" and aria-live="polite". Verify aria-busy="true" on the container while loading.
3. Reduced-motion check. In DevTools → Rendering, set Emulate CSS media feature prefers-reduced-motion to reduce, then trigger loading. The shimmer sweep must stop—the skeleton should show a flat fill—and the spinner should fall back to its static or slowed variant. Repeat with the real OS "Reduce motion" setting to exercise the useReducedMotion change listener.
4. Focus check. Tab to a control, trigger a load, and confirm focus stays on that control throughout—it must not jump to the spinner. After loading completes, verify focus lands where your design intends (new content heading for a user-requested load, unchanged for a background refresh).
5. Automated assertion. With Testing Library, assert the contract:
import { render, screen } from '@testing-library/react';
import { LoadingPanel } from './LoadingPanel';
test('exposes loading state to assistive tech', () => {
render(<LoadingPanel loading>{<p>Data</p>}</LoadingPanel>);
const status = screen.getByRole('status');
expect(status).toHaveTextContent(/loading/i);
expect(status).toHaveAttribute('aria-live', 'polite');
// The decorative spinner is hidden, so it is not an accessible element.
expect(screen.queryByRole('img')).toBeNull();
});
Common a11y Mistakes
- Silent spinner: A rotating SVG with no
role="status"region tells screen-reader users nothing. Always pair decoration with an announcement. - Double announcement: Labeling the spinner with
aria-label="Loading"and rendering a live region reports the same event twice. Use only the live region. - Exposed decoration: Forgetting
aria-hidden="true"on the spinner lets it clutter the accessibility tree. Hide it. - Shimmer that ignores reduced motion: A looping gradient sweep violates the spirit of
2.2.2/2.3.3. Gate it behindno-preferenceand fall back to a static fill. - Focusing the indicator: Moving focus to a spinner that will unmount leaves focus on a detached node. Leave focus in place.
- Chatty live region: Re-announcing "Loading" on every poll floods the speech queue. Announce once at start, once at finish.
Frequently Asked Questions
Should I use role="status" or role="alert" for a loading indicator?
Use role="status", which is polite and queues the announcement without interrupting. role="alert" is assertive and interrupts the user immediately—appropriate for errors, not for routine loading. For loading you want to inform, not interrupt.
Do I need both aria-busy and role="status"?
They do different jobs. aria-busy="true" marks a region whose contents are mid-update so assistive technology knows not to trust them yet; role="status" delivers the human-readable "Loading" announcement. Use aria-busy on the container and role="status" on a concise text node inside it.
How do I stop skeleton shimmer for motion-sensitive users?
Define the shimmer animation only inside @media (prefers-reduced-motion: no-preference) so reduced-motion users automatically get a static skeleton. For JavaScript-driven variants, gate the animated class on a useReducedMotion() hook and swap to a flat or gently pulsing fill.
Should loading indicators move keyboard focus? No. Loading is transient and the indicator will unmount, so focusing it strands the keyboard user on a detached node. Let focus rest where it was; the polite live region announces progress without moving the cursor. Move focus only after loading completes, and only when the interaction warrants it.