Skip to Content
Component ExamplesLoading States

Loading States & Skeleton Screens

Communicating loading progress to screen reader users.

Maintained by Martin KrugerLast reviewed

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
View inaccessible code
<!-- 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>
Accessible
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=&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>

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

Predicted semantic summary (exact output varies):

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

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>
Accessible

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-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

Predicted semantic summary (exact output varies):

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

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.


Resources