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.
- 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.
- Hook event listeners up to the elements that need them. In this case the select element takes an event listener.
- 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:
- The class
.sr-only
hides the non-interactive markup from regular view, but shows it to screen readers. The interactive element has.sr-only-interactive
applied, which will make it visible on focus, to help sighted users using keyboard navigation. - The live region div gets live region status from its aria-live attribute,
aria-live="assertive"
. - The live region text will be completed by the javascript.
- There's a header element above the interface that helps explain what the reader will get.
<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.