Accessible navigation using React Router

This is a quick wrap of my research for creating accessible navigation for Windmill Dashboard (a React aplication) using React Router.

The problem

As React Router manipulates the native browser to create an illusion of navigation, if you go from one route to another using a screen reader (in my case NVDA - free, Windows), it doesn't announce anything. There's just silence.

The solution

At the time of writing this article, there's a 3 year discussion going on React Router, so we will need to implement it by ourselves.

Looks like we need this piece of code to appear on every page, and it needs to be updated on every location change:

<span aria-live="polite" aria-atomic="true">
  Navigated to <page> page.
</span>

aria-live provides a way to programmatically expose dynamic content changes in a way that can be announced by assistive technologies (MDN). aria-atomic is used to set whether or not the screen reader should always present the live region as a whole, in other words, true tells the screen reader to read again the whole page (MDN).

So let's put it inside a component, that I will call AccessibleNavigationAnnouncer.js:

import React from 'react'

function AccessibleNavigationAnnouncer({ page }) {
  return (
    <span aria-live="polite" aria-atomic="true">
      Navigated to {page} page.
    </span>
  )
}

export default AccessibleNavigationAnnouncer

Now we need to find a way to make it update on every location change. I was thinking of creating a new Context, and every page would connect to it and receive/dispatch changes. But it seems a bit overkill. Instead, I will use this component in the root router and update itself on changes.

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

function AccessibleNavigationAnnouncer() {
  // the message that will be announced
  const [message, setMessage] = useState('')
  // get location from router
  const location = useLocation()

  // only run this when location change (note the dependency [location])
  useEffect(() => {
    // ignore the /
    if (location.pathname.slice(1)) {
      // make sure navigation has occurred and screen reader is ready
      setTimeout(() => setMessage(`Navigated to ${location.pathname.slice(1)} page.`), 500)
    } else {
      // in my case, / redirects to /dashboard, so I found it better to
      // just ignore the / route
      setMessage('')
    }
  }, [location])

  return (
    // .sr-only comes from Tailwind CSS and makes sure it will only be visible for SRs
    <span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
      {message}
    </span>
  )
}

export default AccessibleNavigationAnnouncer

My App looks like this:

function App() {
  return (
    <>
      <Router>
        {/* Here we have our announcer. Note: useLocation will only work inside a Router */}
        <AccessibleNavigationAnnouncer />
        <Switch>
          <Route path="/login" component={Login} />
          <Route path="/create-account" component={CreateAccount} />
          <Route path="/forgot-password" component={ForgotPassword} />

          {/* Place new routes over this */}
          <Route path="/" component={Layout} />
        </Switch>
      </Router>
    </>
  )
}

Hope it helps you create better apps.

@estevanmaito

Good readings