Accessible Loading Indicators—with No Extra Elements!

A user waits at their computer
James Steinbach

UX Developer

James Steinbach

It’s almost expected that web apps (no matter what framework or language they use!) will need some time to process their response to user actions. Those delays can be tied to interactions like submitting forms, changing routes, loading content from an API, and uploading files, to name a few. Unfortunately, some apps seem to expect users to instinctively sit still and wait during these asynchronous functions: they don’t bother providing any visual or semantic clues that the app is busy!

We’re going to look at some HTML & CSS that allow us to communicate to users that part of the page is waiting on an async response. These features will communicate to both sighted and screen reader users that the app is busy and they need to wait.

In this post, we’ll gradually improve a block of sample code. For this example, we’ll build a random news article container. Every time a user clicks the “Show New Post” button, the container will load a new article.

<section class="news-wrapper">
  <article class="news">
    <h2 class="news__title"></h2>
    <img class="news__artwork" src="" alt="" />
    <div class="news__content"></div>
  </article>
</section>

Accessible Loading Indicator: ARIA Attributes

aria-live

There’s a little-known ARIA attribute that tells screen readers and other assistive tech that part of the app contains dynamic content: aria-live. The aria-live attribute takes three values: off, polite, and assertive / rude. Generally speaking, polite is a good default: it won’t interrupt the user if they’re listening to their assistive tech tell them about other parts of the page and it’s supported by all major screen readers. Alternatively, the assertive and rude values will immediately interrupt the user with updates on the element’s content (note: there are some differences between which screen readers support which value). We’ll start by adding aria-live="polite" to our container:

<section class="news-wrapper" aria-live="polite">
  <!-- contents -->
</section>

aria-busy

Once a container is aria-live, we can use aria-busy to tell assistive tech that the container is getting new content. When the container is not refreshing, aria-busy will be false, and when it is waiting for new content, it’ll be true. Let’s add that to our sample code:

<!-- while reloading -->
<section class="news-wrapper" aria-live="polite" aria-busy="true">
  <!-- contents -->
</section>
<!-- while stable -->
<section class="news-wrapper" aria-live="polite" aria-busy="false">
  <!-- contents -->
</section>

Now our container is correctly signalling to screen readers when our container is “busy” getting new content! Additionally, it’ll automatically read the new content to users without requiring additional interaction from them.

Visual Loading Indicator: CSS Pseudo-Elements

Now that we’ve gotten our news container built to serve users relying on assistive tech, let’s add some styles so that sighted users will also know when our container is getting new content.

I’m not going to get into all the container’s layout styles: if you’re doing this in real life, you know your unique CSS concerns better than I do. I’ll just provide the bare minimum for a CSS-only loading indicator. We’re going to use a CSS Grid trick to make some layout overlapping simpler, but if your support requirements don’t match that, there are CSS position fallback solutions, too.

Container Layout

Let’s put some minimal layout CSS on our container. Note: I’ll be using nesting in these code samples, like you’d use in Sass or PostCSS with a nesting plugin.

.news-wrapper {
  /* 1. Grid Layout */
  display: grid;
  grid-template: "content" 100% / auto;

  &::after {
    /* 2. Grid Positioning */
    grid-area: content;
    align-self: center;
    justify-self: center;

    /* 3. Indicator Styles */
    content: "";
    margin: 3rem auto;
    width: 4rem;
    height: 4rem;
    display: block;
    border: .5rem solid blue;
    border-left-color: transparent;
    border-radius: 50%;
    opacity: 0;
    transition: opacity .1s;
    pointer-events: none;
    animation: loading-circle 1s ease-in-out infinite;
  }
}

.news {
  /* 2. Grid Positioning */
  grid-area: content;
}

@keyframes loading-circle {
  to {
    transform: rotate(360deg);
  }
}

Let’s take that CSS apart, section by section.

1. Grid Layout

This CSS makes our container a CSS Grid.

.news-wrapper {
  display: grid;
  grid-template: "content" 100% / auto;
}

This CSS grid-template declaration (shorthand for grid-template-rows, grid-template-columns, and grid-template-areas) creates one column (100% width) and one row (auto height: total height is set by content height), and it names that grid area content. “Why use a Grid if it’s only 1×1?” you may be asking. That’s a great question. Setting up 1×1 Grid allows us to position our contents without relying on position and related measurements. That brings us to internal positioning.

2. Grid Positioning

You may be used to position: relative (on a parent) and position: absolute (on a child) for centering a child in a parent when the parent contains normal content. However, using Grid lets us do that with fewer side effects.

.news {
  grid-area: content;
}

.news-wrapper::after {
  grid-area: content;
  align-self: center;
  justify-self: center;
}

We’ve positioned each child of the Grid container inside the content area. This will cause those elements (.news and .news-wrapper::after) to overlap. Even when there’s a .news article inside the container, both it and the ::after will be in content area, overlapping one another. Additionally, the ::after will be centered inside the container.

3. Indicator Styles

To summarize the visual styles, this loading indicator is an open, rotating circle. It’s a larger version of buffering spinners you might see in a media streaming site or app.

Notice that we’ve included pointer-events: none. This element is positioned above the .news (because it’s later in the DOM), which means even with opacity: 0, it’ll prevent users from clicking on parts of .news that are right behind it. Its purpose is visual decoration, so removing pointer-events prevents the spinner from “getting between” the user and the actual content. Note: we could also solve the overlap problem by changing z-index when it’s visible, but that requires adding a position property, and we’re keeping the CSS as simple as we can.

Styling with the Attributes

The last step is the selector for showing the spinner. When the content is busy reloading, we want to make .news less visible and make the spinner visible. We could use a class for that (you might use .is-reloading, if you use SMACSS state classes, for example). But we’ve already got a selector in the DOM and there’s no need to duplicate it with another class. Instead we can tie our last few lines of CSS directly to the aria-busy="live" attribute.

.news-wrapper[aria-busy="true"] {
  .news {
    opacity: .2;
  }

  &::after {
    opacity: 1;
  }
}

Conclusion

In this project, we started by considering users who rely on assistive tech to access our site. As we continued, we discovered that by providing a good experience for them, we already had our selectors ready to go for providing visual cues to sighted users. If you’d like to see a demo of the whole thing put together, check out the CodePen below. And of course, if you turn on VoiceOver or another screen reader, you’ll see how both semantic and visual affordances are aligned for users.

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box