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.

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

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

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>