March 28th, 2021
Welcome back to Modals in React!
This is a two part tutorial to create a reusable, accessible React modal component.
Part 1: Contains the starting code for this article. Focuses on creating a Portal to house our Modal, and making it functional and reusable with props.
Part 2: This article, focuses on adding appropriate ARIA and controlling focus between the modal and the main page to make our component accessible (awesome).
This article assumes a basic familiarity with React, hooks and the concept of state.
We will:
Add appropriate ARIA
Close the Modal with the esc key, or by clicking outside the Modal window
Freeze scrolling on the main page beneath the Modal
Shift focus to the Modal buttons when it opens
Trap focus inside the Modal while it's open
Return focus to its former position when the Modal closes
ARIA is a set of HTML attributes that improve accessibility for assistive technology users. It bridges the gap between native HTML elements and screen readers. Screen readers can understand our semantic HTML, like headings and paragraphs. Without a <modal> element, our component may appear to be a jumble of divs. ARIA help assistive technology understand complex components like this.
Following best practices outlined by WAI-ARIA
- The element that serves as the dialog container has a role of dialog
- All elements required to operate the dialog are descendants of the element that has role dialog
- The dialog container element has aria-modal set to true
- The element has either: a value set for the aria-labelledby property that refers to a visible dialog title or a label specified by aria-label
Let's implement this. The outer div (dark background) of our Modal component needs a role of dialog, and aria-modal set true. We also need to specify a good descriptive label for our component. The <h3> element which contains our title is a good choice. Set an id on the h3 and reference it with labelledby on the outer div.
Great! Now screen readers can interpret our component as a Modal element.
1. Clicking outside the Modal window will close it
We want the Modal to close when we click on the overlay - outside the dialog window. Let's try this with an event listener on the outer div. First, we need to add the setOpen method to the props we pass to the Modal. Next, add an onClick handler to set open to false.
Let's test this in the browser. Start up your app with npm start if it's not already running. Try opening the Modal and then clicking on the overlay. It closes, right? Try opening the modal again, this time click on the white dialog window. This will also close the Modal, but that's not the behavior we want. The user needs to interact with the Modal content, including being able to click on it. Let's prevent this behavior from spilling into the dialog window with stopPropagation, which takes the event object.
2. Pressing the esc key will close the Modal
Let's add an event listener to the window for the 'keydown' event. On that event, we'll call a method which will close the Modal if the key === 'Escape'. The event listener should be removed when the Modal closes. We'll utilize the useEffect hook to set the listener when the component appears and remove it when the Modal closes.
If you're unfamiliar with keyboard events, try console.log(event) in the closeOnEscape method below to see the event object.
Okay, I want to take a moment to clean up our logic a bit to prepare for the next steps.
We're about to add more code around opening and closing the Modal, like scroll locking. Let's move all our Modal opening code into one method, and the closing logic into another. This will help us stay organized and make it easier to understand.
First, let's create two methods: openModal and closeModal.
Move setOpen(true) into openModal, and replace the setOpen(true) everywhere it appears with the openModal function.
Do the same with setOpen(false) and closeModal.
Okay, now we're ready to add more functionality!
When our Modal is open, we want to stop any interaction with the main page underneath it. So, the user should not be able to scroll down the main page and check out more content while the Modal is open. Some sites implement this behavior to stop users who aren't logged in from accessing paid content. To do this, we will override the default browser behavior and disable scrolling. This can be accomplished by toggling a CSS class with overflow: hidden. We'll also add the important tag, just in case some conflicting CSS emerges. We want this class to take priority.
First, add the CSS class to our App.css file. Then, in our EventCard component, create a method: setScrollLock to toggle the class. When the Modal opens, set open true and toggle a class on our HTML. Finally, we'll call this method in our openModal and closeModal methods. When the Modal opens, the main page should freeze. When it closes, scrolling should be restored.
When the Modal opens, the focus should immediately shift from the main page to the action inside the Modal window. We're going to force focus onto our action buttons. In order to do this, we'll need a way to directly target the button to focus on when the Modal opens. We'll use a combination of the useRef and useEffect hooks to achieve this. useRef allows use to target a specific element easily in our JSX. The useEffect hook will utilize the reference to direct focus when the component mounts.
First, in the EventCard component, we'll set the tabIndex='-1' on our Modal. You can tab through clickable content on a webpage. Tabindex is used to set the order for how to tab through the page. By setting this to a negative value, we're taking our element out of that sequential order. This is useful when you want to manually control focus on the page, which is what we want to do.
Next, import { useRef } from react in the EventCard component. Then, create the shiftFocusRef. We'll set ref={shiftFocusRef} on the Cancel button in the modalActions method - which defines the buttons in the Modal. The purpose of our current Modal is to prevent a user from accidentally deleting an item. So, to further this goal, we set focus on the Cancel button, not the Delete button. Finally, pass the shiftFocusRef as a prop to the Modal, so we can utilize the focus method.
In the Modal component's useEffect hook set .focus() on the element currently being referenced by focusButton. Now, the Cancel button is focused when the Modal opens.
We're going to do something remarkably similar to return the focus to its previous location when the Modal closes. We'll define a ref, assign it to the button which triggers the Modal, and call .focus() on that element in our closeModal method. Ready?
In EventCard create the returnFocusRef. In the return statement, add ref={returnFocusRef} to the button. In the closeModal method all returnFocusRef.current.focus()
The Modal should trap focus, and prevent the user from doing anything on the page until it's closed. Right now, if we open the Modal and tab through the page, you'll notice we can tab through not only the Modal window buttons, but all the buttons on the main page as well. We need to prevent this behavior. We'll create a list of tabbable elements inside the Modal, and prevent the user from tabbing out of it.
First, we'll create a list of tabbable elements that exist in our Modal window. We want this list to be created when the Modal appears, because the content will differ depending on what actions are being passed as props into the component. The actions are rendered in the 'modal-actions' div. First, create a ref for that div, and utilize it in the return statement. Then, in our Modal's useEffect hook, we'll add a querySelectorAll to the .current and pass it a list of tabbable elements to find.
We're using our useEffect hook because we only want this logic to run once - when the modal renders to the DOM. However, by creating the variable tabList in the useEffect hook, we lose the ability to reference it elsewhere in our component. The querySelector method will return a node list. I think an array would be easier to work with, because it gives us access to methods like indexOf. Initiate a variable outside the useEffect hook. Within useEffect, spread the tabList values into the tabArray. Now, we can reference tabArray throughout the file and make use of array methods.
If you leave out the // eslint-disable-next-line react-hooks/exhaustive-deps you might notice that React yells at you that changes to tabArray will be lost with each render. This is the behavior we want, so we disable the warning. Each time it renders, we want a new list of the actions defined in that version of the Modal component.
Now, we need to create a function to detect when the user has reached the end of the tabArray, and reroute focus to the next element within the Modal. In our keyEvent function, we'll add an if statement to detect the tab key. Create a method handleTabKey, and pass it the event object.
handleTabKey needs to know when we get to the end of tabArray, and reroute the focus. Find the index of the active element, and set it to a variable. On tab, check if that index is the last item. If so, send the focus to the item at index 0. Users can also use shift+tab to navigate in reverse order. So, if the user is at index 0 and presses shift+tab, they should be routed to the last item.
Our current implementation only has two buttons, but try adding some more tabbable elements to the Modal actions. See how the user can tab through them without escaping the Modal window?
Here's the finished code. I added a little fadein/fadeout animation. It's the same as the vanilla JavaScript modal animation we implemented in a previous article. I hope this has been helpful. As always, feel free to get in touch here or on twitter.
Happy coding!
Finished files: