DEV Community

Cover image for How I approach keyboard accessibility for modals in React
Colette Wilson
Colette Wilson

Posted on

How I approach keyboard accessibility for modals in React

A couple of disclaimers before I start:

  • This is not an article on how to manage modals in React, this article is about ensuring that modals are accessible for keyboard users.
  • I am not an accessibility expert and, therefore, there may be things that could be better.

Contents:

TL;DR

Checkout my codepen

The Basic Markup

For this demonstration, I have used the useState React hook to set and unset the display state of my modal. Since my components are very simple it’s fairly easy to pass that state from the Page component containing the trigger button directly to the Modal component. In reality, you might use some sort of state management library to do this, I like Zustand, but that’s off-topic. So, to start with my modal component looks like this;

const Modal = ({ close modal }) => {

  return (
    <aside 
      className="modal"
      role="dialog"
      aria-modal="true"
    >
      <div className="modalInner">
        <button className="modalClose" type="button" onClick={closeModal}>
          <span className="visuallyHidden">Close modal</span>
        </button>
        <main className="modalContent">
          ...
        </main>
      </div>
    </aside>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see I have an aside, this acts as a fullscreen background, a div acting as the modal container, a button to close the modal, and a main element containing the content. The modal trigger button on the Page component simply sets the display state to true, this state is then used to display or hide the Modal component. The close button resets the display state to false.

This works perfectly well for mouse users so what’s the problem? Well, at the moment the modal opens on top of the page content without updating the DOMs active element, in other words, the focus will remain on the last focused item somewhere on the page behind the modal, leaving a keyboard user unable to interact with any elements inside the modal. Obviously not ideal so how can we make this more accessible?

Focus Trapping

The answer is to trap the focus in the modal while active. Essentially we need to add some Javascript that will ensure we add focus to the modal so the user can tab around and that they aren’t able to tab away from the modal without first closing it.

The first thing I'm going to do is create a new handleKeydown function. This function will listen for a keypress and where appropriate invoke a further function that will perform a specific action, it looks like this;

// map of keyboard listeners
const keyListenersMap = new Map([
  [9, handleTab],
])

const handleKeydown = evt => {
  // get the listener corresponding to the pressed key
  const listener = keyListenersMap.get(evt.keyCode)

  // call the listener if it exists
  return listener && listener(evt)
}
Enter fullscreen mode Exit fullscreen mode

Here I have a map of key codes and corresponding functions. It's not necessary to structure things this way but I find it easier if I ever need to extend functionality later. handleKeydown listens to the key code of the key that's been pressed then gets and invokes the appropriate function from the map if there is one.

To start with the only key I'm tracking in my map has a key code of 9, the tab key. When tab is pressed the handleTab function should be invoked which looks like this;

const handleTab = evt => {
  let total = focusableElements.length

  // If tab was pressed without shift
  if (!evt.shiftKey) {  
    // If activeIndex + 1 larger than array length focus first element otherwise focus next element
    activeIndex + 1 === total ? activeIndex = 0 : activeIndex += 1

    focusableElements[activeIndex].focus()

    // Don't do anything I wouldn't do
    return evt.preventDefault()
  }

  // If tab was pressed with shift
  if (evt.shiftKey) {
    // if activeIndex - 1 less than 0 focus last element otherwise focus previous element
    activeIndex - 1 < 0 ? activeIndex = total - 1 : activeIndex -= 1

    focusableElements[activeIndex].focus()

    // Don't do anything I wouldn't do
    return evt.preventDefault()
  }
}
Enter fullscreen mode Exit fullscreen mode

There's quite a lot going on here so let's break it down. The first line stores the total number of focusable elements as a variable. This just helps to make things a little more readable. focusableElements is a variable that has been set in a useEffect hook. We'll come to this later. Next, I want to detect whether or not the tab button was pressed in combination with shift. This will determine the direction we cycle through the elements. If just tab was pressed, no shift, we want to cycle forward. I'm using a ternary operator to set the index either to the next item in the array of focusable elements or, if there are no more elements in the array, back to the first element. This way we'll be able to tab infinitely without ever leaving the modal. activeIndex is a variable which on initial load is set to -1. And finally, I need to apply focus to the item in the focusableElements array at the correct index. The final line return evt.preventDefault() is a safety net just to ensure nothing unexpected happens.

When tab is pressed with shift we need to repeat this cycle but in the other direction. So this time the ternary operator will set the index to the previous item in focusableElements unless we're at the beginning of the array in which case it will set the index to the last item in the array.

To get everything hooked up I'm going to use 2 separate React useEffect hooks. The first will query for all the relevant elements within the modal and update the focusableElements variable. Note: The list of queried elements is not exhaustive, this is a small example and you may need to update the list depending on the content of the modal. The second will attach the event listener that will fire the handleKeydown function described above;

React.useEffect(() => {
  if (ref.current) {
    // Select all focusable elements within ref
    focusableElements = ref.current.querySelectorAll('a, button, textarea, input, select')
  }
}, [ref])

React.useEffect(() => {
  document.addEventListener('keydown', handleKeydown)

  return () => {
    // Detach listener when component unmounts
    document.removeEventListener('keydown', handleKeydown)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

As you can see this is where I update the focusableElements variable. I'm using a ref which is attached to the div acting as the modal container so that I can collect all the elements within it. It's not strictly necessary to do this within the useEffect in my example since the content is static but in a lot of cases, the modal content may be dynamic in which case the variable will need to be updated whenever the component mounts.

Closing the Modal

One thing I want to do is to extend my map of key codes to include detection for the escape key. Although there is a button specifically for closing the modal it's kind of a hassle to always have to cycle through all the elements to get to it. It would be nice to allow a user to exit early. So when the escape key is pressed I want to invoke the handleEscape function to close the modal. First I need to extend the keyListenersMap to include the additional key code, it now looks like this;

const keyListenersMap = new Map([
  [27, handleEscape],
  [9, handleTab],
])
Enter fullscreen mode Exit fullscreen mode

Then I need to add the new handleEscape function, which in this example look like this;

const handleEscape = evt => {
  if (evt.key === 'Escape') closeModal()
}
Enter fullscreen mode Exit fullscreen mode

Technically I could call closeModal from the map instead of wrapping it in another function but IRL I often need to do other things in here, for e.g. resetting a form or some other form of clean up.

The final thing I need to do is return focus to the page when the modal closes. First I need to know which element is the currently active element at the time the modal is mounted. When the component mounts I want to set an activeElement variable, const activeElement = document.activeElement on my Modal component. When the component unmounts I simply want to return the focus to that same element. I'm going to update the same useEffect hook where my event listener is attached and detached. In the return function I'm simple going to add, activeElement.focus() so that the useEffect now looks like this;

React.useEffect(() => {   
  document.addEventListener('keydown', handleKeydown)

  return () => {
    // Detach listener when component unmounts
    document.removeEventListener('keydown', handleKeydown)
    // Return focus to the previously focused element
    activeElement.focus()
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

There you have it. A modal that is keyboard friendly.


A couple of things not covered by this blog that you might consider adding as 'nice to haves';

  • Stopping the background page scroll while the modal is active
  • Closing the modal on a background click.

Top comments (2)

Collapse
 
roblevintennis profile image
Rob Levin

Nice write up on react modal implementation that is accessible. I like to put some of these in hooks and so the scroll lock, circular tabbing, escape / click closes are all their own hooks.

Collapse
 
mayankav profile image
mayankav

good one ;)