Getting screen reader to read new content added with JavaScript

Viewed 41718

When a web page is loaded, screen readers (like the one that comes with OS X, or JAWS on Windows) will read the content of the whole page. But say your page is dynamic, and as users performing an action, new content gets added to the page. For the sake of simplicity, say you display a message somewhere in a <span>. How can you get the screen reader to read that new message?

3 Answers

The WAI-ARIA specification defines several ways by which screen readers can "watch" a DOM element. The best supported method is the aria-live attribute. It has modes off, polite,assertive and rude. The higher the level of assertiveness, the more likely it is to interrupt what is currently being spoken by the screen reader.

The following has been tested with NVDA under Firefox 3 and Firefox 4.0b9:

<!DOCTYPE html>
<html>
<head>
  <script src="js/jquery-1.4.2.min.js"></script>
</head>
<body>
  <button onclick="$('#statusbar').html(new Date().toString())">Update</button>
  <div id="statusbar" aria-live="assertive"></div>
</body>

The same thing can be accomplished with WAI-ARIA roles role="status" and role="alert". I have had reports of incompatibility, but have not been able to reproduce them.

<div id="statusbar" role="status">...</div>

It really depends whether you are just adding some messages or replacing large parts of the page.

Messages

There are Aria Live Regions, which announce any change to their contents. This is very useful for status messages and sometimes even used with visually hidden live regions to only address screen reader users.

<button onclick="document.querySelector('#statusbar').innerHTML = new Date().toString()">Update</button>

<div id="statusbar" aria-live="assertive"></div>

The aria-live attribute establishes a live region and its value is a politeness setting, which regulates how likely it is the change will interrupt what is currently being spoken by the screen reader.

Another classic example is inline validation of form fields, where the alert role, a live region, is used to immediately announce the error message to the user:

<label>Day of the week we hate
    <input type="text" aria-describedby="error">
</label>
<div role="alert" id="error" hidden>only Monday is permitted</div>

Large Parts of Content

When JavaScript is changing large parts of the site, like in single page applications, putting everything inside a live region would be overkill and actually very annoying.

To let the user know that content changed after activating a trigger, two approaches exist:

  1. A new state of the trigger is announced, implying that the user can simply continue reading to find the new content
  2. Focus is put either onto the element who’s content changed, or on the first interactive element inside

Simply read on

The first case would be applied if the role of the trigger (or other status information) makes it clear that a content change will happen, so it’s expected.

A classic example is the accordion. It has aria-expanded state, which communicates whether its contents are currently visible or not. If they are, the user will simply continue reading, because contents should follow immediately after.

toggleAccordion = e => {
  const target = document.getElementById(e.currentTarget.getAttribute('aria-controls'));
  e.currentTarget.setAttribute('aria-expanded', ! target.toggleAttribute('hidden'));
}
<!-- soon to be replaced by <details> and <summary> --> 
<button aria-expanded="false" aria-controls="accordion-content" onclick="toggleAccordion(event)">2.1 First Rule of ARIA Use</button>
<blockquote id="accordion-content" hidden>
<p>If you can use a native HTML element [HTML51] or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so. […]</p>
</blockquote>

Put focus on the new content

In the second case focus is set programmatically elsewhere, so that element will be announced. This is particularly helpful if it’s parent elements have grouping roles, so their names will be announced as well, as in the case of a modal dialog.

Another example would be a single page application’s navigation, where the single navigation items are still navigated by means of tab.

To be able to programmatically focus a non-interactive element, but not manually, tabindex="-1" is necessary. Focussing the headline is a best practice.

/* some sort of SPA router */
document.querySelectorAll('nav a').forEach(a => a.addEventListener('click', e => {  
  // hide all visible contents
  document.querySelectorAll('main > :not([hidden])').forEach(c => c.hidden = true);
  document.querySelectorAll('[aria-current]').forEach(c => c.removeAttribute('aria-current'));
  
  // show selected content
  const content = document.querySelector(e.currentTarget.getAttribute('href'));
  content.hidden = false;
  content.querySelector('h1').focus();
  e.currentTarget.setAttribute('aria-current', 'page');
}));
a[aria-current] { font-weight: bold }
<nav>
  <ul>
    <li><a href="#page-1" aria-current="page">Page 1</a></li>
    <li><a href="#page-2">Page 2</a></li>
  </ul>
</nav>

<main>
  <div id="page-1">
    <h1 tabindex="-1">Page 1</h1>
    <p>Many lines of content to follow</p>
  </div>
  <div id="page-2" hidden>
    <h1 tabindex="-1">Page 2</h1>
    <p>Many lines of content to follow</p>
  </div>
</main>

Related