More Accessible Skeletons

I had this post queued up for Halloween because, come on, skeletons, and then life did its thing and now it is a … Thanksgiving post?

Many skeleton patterns do a poor job presenting themselves to screen reader users in any meaningful way. They often stuff aria-busy into their widget, set its value to true when the thing is loading, and leave it at that. Except few screen readers honor aria-busy="true".

This means you need to find some other method to hide the busy content. You could pair aria-busy="true" with aria-hidden="true" to achieve the hiding effect. Or you could use CSS to find any node with aria-busy="true" and set it to display: none, achieving the same effect for screen reader and non-SR users.

HTML & ARIA

If I pull from my block links post (Block Links, Cards, Clickable Regions, Rows, Etc.), I end up with this passable code tweaked to include the skeleton:

<article>

  <div class="skeleton">
    <div aria-hidden="true"></div>
    <div aria-hidden="true"></div>
    <div aria-hidden="true"></div>
    <span>Loading</span>
  </div>

  <div aria-busy="true">
    <h3><a href="[…]">Glossier Roof Party</a></h3>
    <img src="[…]" […] alt="[…]">
    <p>
      Gastropub sartorial venmo hashtag […]
    </p>
  </div>

</article>

The method for making your skeleton will of course vary (for example, <article> is rarely what you would have in practice). In my case I am using some <div>s in the structure of the final card because it was easier for me at the time of prototyping.

Pay attention to the <span> in the <div> with the skeleton class.

CSS

This CSS hides the node with the content (assuming the node exists but the content has not been loaded yet), while also hiding the skeleton when it gets set to aria-hidden="true":

*[aria-busy=true], .skeleton[aria-hidden=true] {
  display: none;
}

The following CSS visually hides the <span>, but still leaves it available for a screen reader:

.visually-hidden, .skeleton > span {
  position: absolute;
  top: auto;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  width: 1px;
  height: 1px;
  white-space: nowrap;
}

It is my standard style block for my visually-hidden class (which is why I left it in the selector). How you reference the styles is up to you; I could have just as easily added the visually-hidden class to the <span> but I wanted to enforce its relation to the skeleton class.

Script

Now the function that hides the skeleton and shows the content needs to do two things:

If you already have some async content action going on, then you probably already have a function to manage some of this. I am not offering anything else here because it will be dependent on your own implementation (which thankfully I do not know).

Example

The following example (at Codepen, or in debug mode) shows a bunch of cards in a loading state. You can press one button to populate each of them with content (after an initial three second delay and then in half-second increments), testing with a screen reader to see the impact of the changing ARIA attributes in action. The other button resets them all to the loading state so you can test again in your next screen reader of choice.

See the Pen UI Skeletons and aria-busy by Adrian Roselli (@aardrian) on CodePen.

Screen Reader Support

My demo works across browsers with current releases of JAWS, NVDA, VoiceOver, and TalkBack.

There is a paragraph at the end of the page that has aria-busy="true" that you can use to test with your configuration. To save you some time, I can tell you that only JAWS 2020 honors it (unless paired with IE11). Narrator (Windows 10 Pro with Edge 87), NVDA (2020.2 with Chrome 87 and Firefox 83), TalkBack (Android 11 with Chrome 86 and Firefox 83), and VoiceOver (iOS 14, macOS 11) treat this content as any other.

Additional Concerns

Skeletons are a bit of an anti-pattern. They are Lilliputian lies you give the user with a grin. A bait-and-switch for the impatient. I urge you to reconsider them if you can. They may have their uses in some places, but they are not a good blanket approach.

I use CSS to make the animation in my example. It is a gradient on a 20 second cycle. However, I also choose to honor users who have set the preference to reduce motion on pages. I only show the animation if the user has not set a preference:

@media (prefers-reduced-motion: no-preference) {
  .skeleton div {
    animation: gradientBG 2s ease infinite;
  }
}

My animation name is gradientBG as defined via @keyframes.

Most skeletons have terrible contrast. Awful. Mine has a 1:1 contrast ratio and if the sun hits my screen just right while my eyes are glazed over from waiting, I may miss these completely. I also consider these a WCAG SC 1.4.11 Non-text Contrast failure since they are being used to convey state.

Finally, for those users who explore a screen by flinging their mouse about (my favorite users when doing interface testing, since you can almost watch what they are thinking), I change the mouse cursor to the waiting pointer:

.skeleton {
  cursor: progress;
}

Now go forth and delete skeletons from your pattern libraries!

Additional Warning

While writing this I came across the Vuetify skeleton, v-skeleton-loader. Vuetify is a Vue UI library based on Material Design. The rendered HTML output of the pattern looks like this:

<div aria-busy="true" aria-live="polite" role="alert" […]>
  […]
</div>

You should already be worried about something that has an alert role yet also wants to be polite. Regardless, there appears to be no test with real content and the lack of notes about screen reader support or workarounds is troubling. If you have an example that I can test in the wild, please share.

In the meantime, probably avoid Vuetify’s skeleton pattern.

Update: 3 December 2020

I updated the example to allow you to set the contrast of the skeleton to the 3:1 ratio that WCAG Success Criterion 1.4.11 Non-text Contrast mandates. I also gave you an option to go full neon.

I also adjusted the animation to stop after 5 seconds, in order to comply with WCAG Success Criterion 2.2.2 Pause, Stop, Hide. You can override it with the button, which makes it easier to see the awesome neon.

29 August 2023: 2.2.2 Note 4 exempts loading indicators if interaction cannot occur for all users while the loader is, er, loading. Skeletons are often used in parallel with other content (think of checking your bank balance while a chunking third-party stock ticker spinning away in the sidebar). As such, this pattern does not get a blanket exemption. Technique C39 :Using the CSS reduce-motion query to prevent motion is sufficient to pass 2.3.3, so it makes sense the Working Group would consider it an option for 2.2.2.

The video shows each of the additional options in case your device does not support them all.

Finally, I found an existing bug against Vuetify’s skeleton and added a note to make the initial report a bit clearer — #10999: [Bug Report] Skeleton loaders should not have aria-busy and alert role. Go like it or hate it or whatever the kids do nowadays on the Github social network.

Update: 14 December 2020

I missed this post, Effective Skeleton Screens, which talks about the visual artifacts and (failed) expectation management of skeleton screens.

While it does not talk about accessibility at all (though it has good alternative text for the images), it is worth considering the larger usability issues with skeletons when applied carelessly.

Even if you can justify their use, now you are on the hook for making them accessible (unless you roll your own).

Update: 26 September 2021

An issue against WCAG has been opened as a result of my post: #2048 Do skeleton loaders fall under “inactive” exemption of 1.4.11 Non-Text Contrast?

I think the main sticking point is how they fit into current WCAG definitions of user interface elements and disabled controls. I make my opinion clear in the thread, but for those interested in the discussion I encourage you to read all the feedback. People far smarter than I are weighing in.

Update: 2 April 2022

I updated the example to support Windows High Contrast Mode, renamed Contrast Themes in Windows 11. I took the 1986 theme and swapped the colors out for system colors and wrapped it in the Frankenquery’s Monster feature query that I outline in my post WHCM and System Colors.

/* Forced colors mode */
@media screen and (-ms-high-contrast: active),
  screen and (forced-colors: active) {
    .skeleton div {
      -ms-high-contrast-adjust: none;
      forced-color-adjust: none;
      background: linear-gradient(-60deg, Canvas 0, Canvas 130px, CanvasText 146px, ButtonText 149px, ButtonText 151px, CanvasText 154px, Canvas 170px, Canvas 100%);
    }
}

This will not work in Firefox at least through version 100 since it does not support forced-color-adjust.

Green angled lines on a black background.
The skeleton as seen in Windows 10 high contrast theme #2, one of the default themes.

The colors will change based on which theme you have running.

Update: 29 August 2023

Added note above on how and when WCAG SC 2.2.2 applies.

17 Comments

Reply

If you have an example that I can test in the wild, please share.

Find a very simplified example here: https://3708o.csb.app/ and the code itself at
https://codesandbox.io/s/vuetify-skeleton-simple-experiment-3708o-

In response to Marcus. Reply

Thanks, Marcus. Oddly, that live region (the node with aria-busy, aria-live, and role="alert") I noted above does not seem to come into play. That entire node gets deleted. It also has no content (no text nodes), so the aria-busy is moot since there is nothing to hide (not even hidden text saying “loading”).

Without knowing the thinking behind assigning all three of those ARIA bits, I cannot fathom why those are there. What I can say is those are code smell for broader ARIA abuse.

In response to Marcus. Reply

Marcus, I amended an existing bug report from 1 April of this year: #10999 [Bug Report] Skeleton loaders should not have aria-busy and alert role.

Reply

I’ve never seen a skeleton that meets Level AA – Non-text Contrast of 3:1. Any thoughts on that?

Jason Pearl; . Permalink
In response to Jason Pearl. Reply

Jason, for .skeleton div, change the background property to give 3:1 contrast with the white background (assuming a white background). Also let the gradient go to white for sufficient contrast within it:

background: linear-gradient(-60deg, #949494 0, #949494 100px, rgba(255,255,255,1) 150px, #949494 200px, #949494 100%);

Though at this point, either go full neon or just do not use a skeleton.

I left styles in the pen that you can uncomment to see how ugly the dark gray is, but how awesome the neon is.

Reply

Adrian: Thanks! I hope the contribution will be taken seriously and not be put aside again

In response to Marcus. Reply

I appreciate your thumbs-up on the issue.

Reply

How do you find the usability of skeletons over a loading spinner/loading text? I’ve been wondering how they perform for some users(low vision, dyslexic etc) given they give the impression of content, but roll an animation and switch to the content when its ready.

In response to Al. Reply

It genuinely depends on design, context, and broader user experience. For lower vision users, skeletons are generally worse since the contrast is so low. A spinner can be a problem for screen magnifier users if the magnifier is not over that part of the screen (obviously the smaller the spinner the bigger the problem). For sufficiently large animations, users with vestibular disorders can find the animations problematic. For screen reader users, the loading state is consistently not properly conveyed, and when it is the live region sometimes announces it after the content has already loaded (or fails to update when it has not). And so on.

Overall, the usability is poor for both. So you should pick based on your audience and your ability to fix the accessibility gaps.

Reply

Any tips for applying something like this to a table?

In response to Eric. Reply

Maybe? Try adding as many child <div>s to the <div class="skeleton"> as there are rows in your table and see how that works? Maybe do not do one for every cell, because that is a lot more <div>s and then you have to lay them out with CSS grid and I can’t imagine that would be fun (or necessary).

Reply

Would it be a good idea to use role="progressbar" on the Skeleton wrapper? I’m thinking the Skeleton pattern functions much the same as an indeterminate spinner and might benefit from the same semantics?

Tommy Feldt; . Permalink
In response to Tommy Feldt. Reply

Consider that the progress role is meant to use aria-busy (though it is not a requirement). The value attributes (aria-valuenow, aria-valuemin, and aria-valuemax could be useful to convey how far along something is, but if the timing is not sufficiently extended then they don’t offer much value. And they should correspond to a visual. And support for them is meh (going from memory, have not tested one recently). So if you exclude them then you just have a low-yield status region in concept with shaky support.

Granted, I have not tested it in a while, so if you give a shot I would be happy to check it out.

Reply

I personally wouldn’t set aria-hidden="true" on the skeleton loader, but instead give it a role of “status” and update the content in the span from “Loading” to “Loaded”.

This way, screenreaders will announce that the particular piece of content has loaded, which might be useful.

Now, this can be a nuisance if you have a lot of single items all with individual skeleton loaders, in which case you might want to hoist it to the parent container or something, or just keep using the current approach. However, notifying the user that content has loaded and is now available is probably very valuable.

My use-case would be more like async loading tabular data for example, where you can notify people when the loading has completed (or failed!) and they can now interact with the data.

Love this approach though, I always find focussing on a11y makes you write better, semantic HTML anyway, so it’s a win-win in my book.

Reinier Kaper; . Permalink
Reply

Hey Adrian, looks like some vestigial CSS on this page is applying #ff8200 where it shouldn’t!

Casey Smith; . Permalink
In response to Casey Smith. Reply

After editing, it all turned green! Now in the respond mode, it’s a sickly green… So I understand now that it’s random, which is neat, but some of the results are brighter than you’d like, I’m sure.

Casey Smith; . Permalink
In response to Casey Smith. Reply

Yeah, I have an array of 8 WCAG-conformant pairs of highlight colors (for the light and dark themes). A set is randomly chosen on page load.

As for colors being applied where they shouldn’t, it would be helpful to know where you saw them (XPath nodes are ideal) so I could debug if they have become sentient or I just fed them after midnight. If you rightly have more important things to do, no worries! I will keep a watch for unintended break-outs.

Leave a Comment or Response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>