One way to make an interactive map accessible to screen readers

Learn how to implement a basic aria live region approach.

By Joe Murphy

First published , last updated

When NBC News published an article about which counties in the U.S. swung the most toward Trump, and what patterns there were among the demographics of those counties, we built a large interactive map that lets readers explore the election results by race, income and education.

This interactive relies on a reader selecting what they want to display, and any time an interactive hides or shows content on the screen, affordances are required to make that information accessible to screen readers.

This is how I made this map accessible to screen readers, and it includes techniques you can take to improve accessibility in your interactives.

Overview: How to make an interactive map accessible with aria live regions

If you're unfamiliar with aria live regions, they are a tool that lets screen readers narrate aloud changes to the page and information about what's happening.

  1. Create interface elements in HTML for the actions the screen reader can perform. In this case, we're creating a select-element list of options and an aria live region to narrate the results of changing that selection.
  2. Hook event listeners up to the elements that need them. In this case the select element takes an event listener.
  3. Write javascript that populates the live region when the interface changes. Here, the liveregion updates with category-specific data, a crude approximation of what the map shows.

Step 1: Create interface elements in HTML for the actions the screen reader can perform and flesh out how the live region text will read

In the original interactive, a javascript library was used to create a drop-down using non-standard HTML elements. That isn't guaranteed to work on a screenreader, so we write some markup that is.

Notes on the markup below:

<section id="interface">
  <h3 class="sr-only">Pick a demographic to get details on how the counties with the most of that demographic’s people voted</h3>
  <div id="liveAnnouncer" role="status" aria-live="assertive" aria-atomic="true" class="sr-only">
    Among the counties with the largest share of <span id="group"></span>,
    voters shifted to Trump by a median of <span id="median"></span> percentage points.
  </div>
<select id="reader" class="sr-only-interactive">
  <optgroup label="Race and ethnicity">
    <option>Asian American</option>
    <option>Black</option>
    <option>Pacific Islander*</option>
    <option>Native American*</option>
...

Step 2: Hook event listeners up to the elements that need them

First, write the event listener:

      document.getElementById('reader').addEventListener('change', (event) => {

And in that event listener, we need some logic to get the actual values used to filter the data. Some of this is made more complex by the way this particular map was built, your mileage may vary.

        var selected = event.target.value
        // Figure out the data key we're looking for
        var key, category
        let cats = Object.keys(categoryLabelLookup)
        cats.forEach(cat => {
          let labels = Object.keys(categoryLabelLookup[cat]['labels'])
          labels.forEach(label => {
            if ( categoryLabelLookup[cat]['labels'][label] == selected ) {
              key = label
              category = cat
            }
          })
        })

Step 3: Write javascript that populates the live region when the interface changes

A lot of what's necessary here depends on the structure of your data and what you're showing on the map. This first part filters the data so we can measure the median vote shift of a particular demographic.

        // Put together the data and call this again
        dataByFips = {}
        var marginValues = []
        jsonData.forEach(d => {
          if (d.category === category && d.label === key) {
            marginValues.push(+d['margin-pct-pt-chg'])
          }
        })

This next part builds the data and the strings we'll need to populate the live region in a way that reads well.

        let medianValue = d3.median(marginValues)
        let desc = selected.replace('*', '')

        // Finesse the label text so it makes sense when read aloud
        if ( category == 'race' ) desc += 's'
        if ( category == 'income' ) desc = `people with incomes of ${desc}`
        if ( category == 'education' ) desc = `people whose education ended with a ${desc}`

And finally, we update the live region with the information.

        document.getElementById('group').textContent = desc
        document.getElementById('median').textContent = medianValue.toFixed(1)

The result: Examples of the text read aloud in the live region

Here are some of the text blurbs that will be read aloud by a screen reader:

Among the counties with the largest share of Hispanics, voters shifted to Trump by a median of 5.6 percentage points.
Among the counties with the largest share of people with incomes of $75k - $100k, voters shifted to Trump by a median of 2.8 percentage points.
Among the counties with the largest share of people whose education ended with a High school diploma, voters shifted to Trump by a median of 3.5 percentage points.

Notes and updates

There's code in this interactive that handles the initial population of the live region text, that code isn't shown here.

Also, I added aria-hidden="true" to the div surrounding the drop-down that's used for sighted people, to make sure that doesn't clutter the screenreader interface.

Also, here's the CSS I use for the .sr-only and .sr-only-interactive classes:

/* Screenreader-only styles from https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034 */
.sr-only {
  border: 0 !important;
  clip: rect(1px, 1px, 1px, 1px) !important; /* 1 */
  -webkit-clip-path: inset(50%) !important;
  clip-path: inset(50%) !important; /* 2 */
  height: 1px !important;
  margin: -1px !important;
  overflow: hidden !important;
  padding: 0 !important;
  position: absolute !important;
  width: 1px !important;
  white-space: nowrap !important; /* 3 */
}
.sr-only-interactive:focus {
  border: 2px solid !important;
  clip: none !important; /* 1 */
  -webkit-clip-path: none !important;
  clip-path: none !important; /* 2 */
  height: auto !important;
  margin: auto !important;
  overflow: visible !important;
  padding: 0 !important;
  position: inherit !important;
  width: auto !important;
  white-space: nowrap !important; /* 3 */
}

Update

Someone pointed out to me that hiding interactive elements with .sr-only is bad practice, the code is updated to reflect that.


You can email me at joe.murphy@gmail.com.