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.
Modal.js
return ReactDOM.createPortal(
<div
className='modal-bg'
// add ARIA
role='dialog'
aria-modal='true'
aria-labelledby='modal1-label'
>
<div className='modal-window'>
<h3
// add id corresponding to aria-labelledby
id='modal1-label'
className='modal-title'
>
{props.title}
</h3>
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.
EventCard.js
{open && (
<Modal
title={modalTitle}
description={modalDescription}
actions={modalActions()}
// add props
setOpen={setOpen}
open={open}
/>
)}
Modal.js
<div
className='modal-bg'
role='dialog'
aria-modal='true'
aria-labelledby='modal1-label'
// add onClick handler
onClick={()=> {props.setOpen(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.
Modal.js
<div
className='modal-bg'
role='dialog'
aria-modal='true'
aria-labelledby='modal1-label'
onClick={()=> {props.setOpen(false)}}
>
<div
// add onClick handler to stop Modal closing
onClick={(event) => event.stopPropagation()}
className='modal-window'
>
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.
Modal.js
// import useEffect
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom'
const Modal = (props) => {
// add method to detect Escape key and close Modal
const closeOnEscape = (event) => {
if (event.key === 'Escape') {
props.setOpen(false)
}
}
// add useEffect hook to add an event listener when component mounts
useEffect(() => {
window.addEventListener('keydown', closeOnEscape)
// return a function to remove the event listener when the Modal closes
return () => {
window.removeEventListener('keydown', closeOnEscape)
}
})
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.
Modal.js
if (event.key === 'Escape') {
// change to closeModal
props.closeModal()
}
}
useEffect(() => {
window.addEventListener('keydown', closeOnEscape)
return () => {
window.removeEventListener('keydown', closeOnEscape)
}
})
return ReactDOM.createPortal(
<div
className='modal-bg'
role='dialog'
aria-modal='true'
aria-labelledby='modal1-label'
onClick={() => {
// change to closeModal
props.closeModal()
}}
>
EventCard.js
// method to handle all opening Modal logic
const openModal = () => {
setOpen(true)
}
// method to handle all closing Modal logic
const closeModal = () =>{
setOpen(false)
}
const modalActions = () => {
return (
<>
<button
className='negative-button'
onClick={() => {
updateEvents(event.id)
// change to closeModal method
closeModal()
}}
>
Delete
</button>
<button
// change to closeModal method
onClick={() => { closeModal()}}
>
Cancel
</button>
</>
)
}
return (
<>
<div className='card'>
<div>
<h4> {event.title} </h4>
<p>{event.description}</p>
</div>
// change to openModal method
<button onClick={() => openModal()}>Delete</button>
{open && (
<Modal
// delete open and setOpen props, replace with closeModal
closeModal={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.
App.css
.scroll-lock {
overflow: hidden !important;
}
EventCard.js
const setScrollLock = () => {
document.querySelector('html').classList.toggle('scroll-lock')
}
const openModal = () => {
setOpen(true)
setScrollLock()
}
const closeModal = () =>{
setOpen(false)
setScrollLock()
}
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.
EventCard.js
// add useRef to import
import React, { useState, useRef } from 'react'
import Modal from './Modal'
const EventCard = ({ event, updateEvents }) => {
// set shiftFocusRef to useRef
const shiftFocusRef = useRef(null)
...
const modalActions = () => {
return (
<>
<button
className='negative-button'
onClick={() => {
updateEvents(event.id)
closeModal()
}}
>
Delete
</button>
<button
ref={shiftFocusRef}
onClick={() => {
closeModal()
}}
>
Cancel
</button>
</>
)
}
...
{open && (
<Modal
// set tabIndex and pass shiftFocusRef as a prop
tabIndex='-1'
shiftFocusRef={shiftFocusRef}
closeModal={closeModal}
title={modalTitle}
description={modalDescription}
actions={modalActions()}
/>
)}
...
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.
Modal.js
useEffect(() => {
window.addEventListener('keydown', closeOnEscape)
// add focus method to current focusButton
props.shiftFocusRef.current.focus()
return () => {
window.removeEventListener('keydown', closeOnEscape)
}
})
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()
EventCard.js
// add the returnFocusRef
const EventCard = ({ event, updateEvents }) => {
const returnFocusRef = useRef(null)
const shiftFocusRef = useRef(null)
...
const closeModal = () => {
setOpen(false)
setScrollLock()
// add the focus method
returnFocusRef.current.focus()
}
...
return (
<>
<div className='card'>
<div>
<h4> {event.title} </h4>
<p>{event.description}</p>
</div>
// add the ref
<button ref={returnFocusRef} onClick={() => openModal()}>
Delete
</button>
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.
Modal.js
const Modal = (props) => {
// create a ref for the modal-actions div
const tabItemRef = useRef(null)
useEffect(() => {
window.addEventListener('keydown', keyEvent)
props.shiftFocusRef.current.focus()
// use querySelectorAll to find all tabbable elements
// in the modal-actions div using the ref
const tabList = tabItemRef.current.querySelectorAll(
'a, button, textarea, input, select'
)
...
// set the tabItemRef on the modal-actions div in the return statement
<div ref={tabItemRef} className='modal-actions'>
{props.actions}
</div>
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.
Modal.js
const Modal = (props) => {
const tabItemRef = useRef(null)
// initiate tabArray
let tabArray
useEffect(() => {
window.addEventListener('keydown', keyEvent)
props.shiftFocusRef.current.focus()
const tabList = tabItemRef.current.querySelectorAll(
'a, button, textarea, input, select'
)
//set it equal to the tabList in array form
// eslint-disable-next-line react-hooks/exhaustive-deps
tabArray = [...tabList]
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.
Modal.js
const keyEvent = (event) => {
if (event.key === 'Escape') {
props.closeModal()
}
if (event.key === 'Tab') {
handleTabKey(event)
}
}
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.
Modal.js
const handleTabKey = (event) => {
const activeIndex = tabArray.indexOf(document.activeElement)
const tabLength = tabArray.length - 1
if (!event.shiftKey && activeIndex === tabLength) {
tabArray[0].focus()
return event.preventDefault()
}
if (event.shiftKey && activeIndex === 0) {
tabArray[tabLength].focus()
return event.preventDefault()
}
}
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:
Modal.js
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
const Modal = (props) => {
const tabItemRef = useRef(null)
let tabArray
useEffect(() => {
window.addEventListener('keydown', keyEvent)
props.shiftFocusRef.current.focus()
const tabList = tabItemRef.current.querySelectorAll(
'a, button, textarea, input, select'
)
// eslint-disable-next-line react-hooks/exhaustive-deps
tabArray = [...tabList]
return () => {
window.removeEventListener('keydown', keyEvent)
}
})
const keyEvent = (event) => {
if (event.key === 'Escape') {
props.closeModal()
}
if (event.key === 'Tab') {
handleTabKey(event)
}
}
const handleTabKey = (event) => {
const activeIndex = tabArray.indexOf(document.activeElement)
const tabLength = tabArray.length - 1
if (!event.shiftKey && activeIndex === tabLength) {
tabArray[0].focus()
return event.preventDefault()
}
if (event.shiftKey && activeIndex === 0) {
tabArray[tabLength].focus()
return event.preventDefault()
}
}
return ReactDOM.createPortal(
<div
className='modal-bg'
role='dialog'
aria-modal='true'
aria-labelledby='modal1-label'
onClick={() => {
props.closeModal()
}}
style={{ animation: `${!props.fadeout ? 'fadein' : 'fadeout'} 1s` }}
>
<div
onClick={(event) => event.stopPropagation()}
className='modal-window'
>
<h3 id='modal1-label' className='modal-title'>
{props.title}
</h3>
<p className='modal-description'>{props.description}</p>
<div ref={tabItemRef} className='modal-actions'>
{props.actions}
</div>
</div>
</div>,
document.querySelector('#modal')
)
}
export default Modal
EventCard.js
import React, { useState, useRef } from 'react'
import Modal from './Modal'
const EventCard = ({ event, updateEvents }) => {
const returnFocusRef = useRef(null)
const shiftFocusRef = useRef(null)
const [open, setOpen] = useState(false)
const [fadeout, setFadeout] = useState(false)
const setScrollLock = () => {
document.querySelector('html').classList.toggle('scroll-lock')
}
const openModal = () => {
setOpen(true)
setScrollLock()
}
const closeModal = () => {
setFadeout(true)
setTimeout(() => {
setFadeout(false)
setOpen(false)
setScrollLock()
returnFocusRef.current.focus()
}, 200)
}
const modalTitle = 'Delete Event'
const modalDescription = `Are you sure you want to delete "${event.title}"?`
const modalActions = () => {
return (
<>
<button
className='negative-button'
onClick={() => {
updateEvents(event.id)
closeModal()
}}
>
Delete
</button>
<button
ref={shiftFocusRef}
onClick={() => {
closeModal()
}}
>
Cancel
</button>
</>
)
}
return (
<>
<div className='card'>
<div>
<h4> {event.title} </h4>
<p>{event.description}</p>
</div>
<button id={event.id} ref={returnFocusRef} onClick={() => openModal()}>
Delete
</button>
{open && (
<Modal
fadeout={fadeout}
eventId={event.id}
tabIndex='-1'
shiftFocusRef={shiftFocusRef}
closeModal={closeModal}
title={modalTitle}
description={modalDescription}
actions={modalActions()}
/>
)}
</div>
</>
)
}
export default EventCard
DisplayEvent.js
import React, { useState } from 'react'
import { events } from './events'
import EventCard from './EventCard'
function DisplayEvents() {
const [eventList, setEventList] = useState(events)
const updateEvents = (id) => {
const newList = eventList.filter((event) => {
return event.id !== id
})
setEventList(newList)
}
const renderEvents = () => {
return eventList.map((event) => {
return <EventCard event={event} key={event.id} updateEvents={updateEvents} />
})
}
return <div>{renderEvents()}</div>
}
export default DisplayEvents
App.css
.App {
max-width: 600px;
margin: 20px auto;
}
button {
border-radius: 3px;
padding: 7px;
border: solid 1px #BBBFC3;
cursor: pointer;
margin: 0 7px;
}
.negative-button {
background-color: #ff6961;
}
.card {
max-width: 375px;
box-shadow: 2px 2px 2px 1px rgba(208,213,217, .2);
padding: 10px;
margin: 5px auto;
border: solid 1px #BBBFC3;
border-radius: 3px;
}
/* Modal Styles */
.modal-bg {
background-color: hsla(170, 5%, 15%, 0.8);
position: fixed;
top: 0;
left:0;
right: 0;
bottom: 0;
}
.modal-window {
width: 375px;
height: 200px;
border-radius: 5px;
background-color: #fff;
margin: 100px auto;
padding: 20px;
}
@media screen and (max-width: 500px) {
.modal-window {
width: 250px;
height: 250px;
}
}
.modal-title {
text-align: center;
}
.modal-actions {
padding: 10px;
text-align: center;
}
.scroll-lock {
overflow: hidden !important;
}
@keyframes fadein {
from {
opacity:0;
}
to {
opacity:1;
}
}
@keyframes fadeout {
from {
opacity:1;
}
to {
opacity:0;
}
}
.fade-in{
animation:fadein 1s;
}
.fade-out{
animation:fadeout .5s;
}
App.js
import './App.css';
import DisplayEvents from './DisplayEvents'
function App() {
return (
<div className="App">
<DisplayEvents/>
</div>
);
}
export default App;