Skip to Content
Component ExamplesLoading States

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:

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" with aria-valuenow, aria-valuemax, and aria-valuetext for determinate progress
  • aria-valuetext should be human-readable (e.g., “Uploading, 45% complete”) not just a number
  • Remove aria-busy and aria-hidden when loading finishes so the real content becomes available

Loading Container

Silent Spinner vs. Announced Loading State

Inaccessible
<!-- Visual-only spinner — no announcement of loading or completion --> <button onclick=" var content = document.getElementById('content'); content.innerHTML = '<div class=&quot;spinner&quot;></div>'; setTimeout(function() { content.innerHTML = '<p>User Profile</p><p>Name: Jane</p>'; }, 2000); "> Load Data </button> <div id="content"></div>
Live Preview
Accessible
<button onclick=" var content = document.getElementById('content'); var sr = document.getElementById('sr-status'); content.setAttribute('aria-busy', 'true'); content.innerHTML = '<div aria-hidden=&quot;true&quot;>' + ' <div class=&quot;skeleton-line&quot;></div>' + ' <div class=&quot;skeleton-line&quot;></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>
Live Preview

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-busy on 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:

VersionAnnouncement
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 false after loading, the container signals that its content is ready to be consumed

Progress Bar

Visual-Only Progress Bar vs. Accessible Progressbar

Inaccessible
<!-- 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>
Live Preview

Uploading file...

45%

Accessible
<!-- 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>
Live Preview

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-valuenow or aria-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:

VersionAnnouncement
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-valuenow AND aria-valuetext together as the progress changes

Key Teaching Points

TechniquePurposeWhere to apply
aria-busy="true"Suppress announcements during loadingOn the parent container being updated
aria-hidden="true"Hide skeleton placeholders from screen readersOn skeleton/placeholder elements
aria-live="polite"Announce loading start and completionOn a separate status span, not on the content container
role="progressbar"Expose determinate progressOn the progress track element
aria-valuetextHuman-readable progress descriptionOn the role="progressbar" element

Resources