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
<!-- 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><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
What the screen reader announces:
| 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
<!-- 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...
45%
<!-- 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>Uploading file...
0%
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
What the screen reader announces:
| 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 |