How I approach keyboard accessibility for modals in React
Colette Wilson
Posted on June 13, 2021
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
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鈥檚 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鈥檚 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>
)
}
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鈥檚 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鈥檛 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)
}
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()
}
}
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)
}
}, [])
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],
])
Then I need to add the new handleEscape
function, which in this example look like this;
const handleEscape = evt => {
if (evt.key === 'Escape') closeModal()
}
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()
}
}, [])
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.
Posted on June 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.