Loading States & Skeleton Screens
Communicating loading progress to screen reader users.
Overview
Loading states are everywhere — skeleton screens, spinners, progress bars — but they are almost always visual-only. A screen reader user who triggers a data load hears nothing: no indication that loading has started, no feedback when content appears. The key tools are aria-busy on the parent container to suppress announcements during loading, aria-hidden on skeleton placeholders so they are not read as content, and aria-live regions to announce when loading completes. For determinate progress, role="progressbar" with aria-valuenow and aria-valuetext gives users a human-readable status.
WCAG Criteria:
- 4.1.3 Status Messages — loading status and completion must be announced without moving focus
- 1.3.1 Info and Relationships — skeleton placeholders and progress indicators must convey their purpose programmatically
Key requirements:
- Set
aria-busy="true"on the parent container (not the spinner) during loading — this tells assistive technology to wait before announcing content changes - Hide skeleton screens with
aria-hidden="true"so screen readers do not attempt to read placeholder shapes as content - Announce load completion via an
aria-live="polite"region with a clear message like “Content loaded” - Use
role="progressbar"witharia-valuenow,aria-valuemax, andaria-valuetextfor determinate progress aria-valuetextshould be human-readable (e.g., “Uploading, 45% complete”) not just a number- Remove
aria-busyandaria-hiddenwhen loading finishes so the real content becomes available
Loading Container
Silent Spinner vs. Announced Loading State
View inaccessible code
<!-- Visual-only spinner — no announcement of loading or completion -->
<button onclick="
var content = document.getElementById('content');
content.innerHTML = '<div class="spinner"></div>';
setTimeout(function() {
content.innerHTML = '<p>User Profile</p><p>Name: Jane</p>';
}, 2000);
">
Load Data
</button>
<div id="content"></div>View accessible code
<button onclick="
var content = document.getElementById('content');
var sr = document.getElementById('sr-status');
content.setAttribute('aria-busy', 'true');
content.innerHTML =
'<div aria-hidden="true">' +
' <div class="skeleton-line"></div>' +
' <div class="skeleton-line"></div>' +
'</div>';
sr.textContent = 'Loading content';
setTimeout(function() {
content.setAttribute('aria-busy', 'false');
content.innerHTML =
'<p>User Profile</p><p>Name: Jane</p>';
sr.textContent = 'Content loaded';
}, 2000);
">
Load Data
</button>
<!-- aria-busy on parent suppresses announcements during load -->
<div id="content"></div>
<!-- Live region announces loading and completion -->
<span id="sr-status" aria-live="polite" class="sr-only"></span>What’s wrong with the silent spinner?
- The spinning circle is purely visual — screen readers announce nothing when loading starts
- When content replaces the spinner, there is no announcement — screen reader users do not know the content has arrived
- No
aria-busyon the container — if a screen reader happens to read the region during loading, it may read partial or placeholder content - The spinner itself has no accessible text — even if a screen reader encountered it, it would be meaningless
Predicted semantic summary (exact output varies):
| Version | Announcement |
|---|---|
| Inaccessible (visual spinner) | Nothing — silence during load, silence after content appears |
Accessible (aria-busy + live region) | “Loading content” when loading starts, “Content loaded” when data appears |
Why aria-busy goes on the parent container:
aria-busy="true"tells assistive technology that the container’s content is being updated and should not be read yet- Without it, a screen reader navigating into the loading area might read skeleton placeholder text or partial content
- When set to
falseafter loading, the container signals that its content is ready to be consumed
Progress Bar
Visual-Only Progress Bar vs. Accessible Progressbar
Uploading file...
45%
View inaccessible code
<!-- Visual-only progress bar — no role, no values, invisible to screen readers -->
<p>Uploading file...</p>
<div style="width: 100%; height: 20px; background: #e2e8f0; border-radius: 10px;">
<div id="bar" style="
width: 45%;
height: 100%;
background: #2563eb;
border-radius: 10px;
"></div>
</div>
<p>45%</p>Uploading file...
0%
View accessible code
<!-- Semantic progressbar with human-readable valuetext -->
<p id="upload-label">Uploading file...</p>
<div role="progressbar"
aria-valuenow="45"
aria-valuemin="0"
aria-valuemax="100"
aria-valuetext="Uploading, 45% complete"
aria-label="File upload progress"
style="width: 100%; height: 20px; background: #e2e8f0;">
<div id="bar" style="
width: 45%;
height: 100%;
background: #2563eb;
"></div>
</div>What’s wrong with the visual-only progress bar?
- No
role="progressbar"— screen readers see a generic<div>, not a progress indicator - No
aria-valuenoworaria-valuemax— there is no programmatic way to determine completion percentage - No
aria-valuetext— even if a screen reader could read the value, “45” alone is not meaningful without context - The “45%” text is visually present but not associated with the progress bar in any programmatic way
- A screen reader user who clicks “Upload” has no feedback about how far along the upload is
Predicted semantic summary (exact output varies):
| Version | Announcement |
|---|---|
| Inaccessible (visual div) | Nothing — screen reader sees generic text and a div |
Accessible (role="progressbar") | “File upload progress, progressbar, Uploading, 45% complete” |
Why aria-valuetext matters:
aria-valuenow="45"gives screen readers a raw number, but “45” alone is ambiguous — 45 of what?aria-valuetext="Uploading, 45% complete"provides a human-readable string that screen readers prefer over the numeric value- When the upload finishes, setting
aria-valuetext="Upload complete"gives a clear completion message - Always update
aria-valuenowANDaria-valuetexttogether as the progress changes
Key Teaching Points
| Technique | Purpose | Where to apply |
|---|---|---|
aria-busy="true" | Suppress announcements during loading | On the parent container being updated |
aria-hidden="true" | Hide skeleton placeholders from screen readers | On skeleton/placeholder elements |
aria-live="polite" | Announce loading start and completion | On a separate status span, not on the content container |
role="progressbar" | Expose determinate progress | On the progress track element |
aria-valuetext | Human-readable progress description | On the role="progressbar" element |
Use one purposeful message channel for a state change. Do not expose “Loading” simultaneously through a status region, a progressbar value, and changing button text unless each conveys distinct information. Announce the start when it is not already evident from the invoked control, announce completion when it matters, and avoid narrating indeterminate animation frames or every percentage change.