How to create an accessible Modal component in React—Part 1
April 30, 2022• ☕️☕️ 6 min read
Modals are used to grab the user’s attention to perform a task. They put your system into a special mode requiring user interaction. As a web developer, you might have used them in your app, either via an open-source package or by constructing your own from scratch. And most of the time, if it’s the latter case, I have seen that the resulting Modal components are not keyboard friendly, and don’t have a great UX either.
I believe this is due to the following reasons:
- lack of knowledge around the accessibility guidelines and UX requirements to create a modal using React.
- React has made creating UI much easier for developers, so they don’t bother checking WCAG, and end up implementing a UI that only works with a mouse.
Well, in this series, we’ll look into how to create an accessible Modal component from scratch without using any library. We’ll follow the WAI-ARIA guidelines for the modal dialog, and enhance its UX as we go. This will also help us understand how to use the native Web API to create an element without relying upon open-source packages, which are mostly a black box to most of us. Additionally, you will learn what it takes to create a UX that your users, including screen readers, will love to use.
The first part will:
- Lay down the foundation of our modal: the bare minimum.
- Add interactivity so that it can be used with a mouse, including the
backdrop
(content behind the modal) click to automatically close the modal. - Inject the right
ARIA attributes
as described in the WAI-ARIA practices guidelines to make our modal friendly to assistive technologies.
In the later articles of this series, we shall look into how to manage the placement of the focus when the modal is open and close (part 2), followed by how to make it keyboard accessible (part 3). So, without further ado, let’s get started with our first part.
Table of Contents
A Modal is born—the dialog container
We will start by creating a modal
container component that will accept the modal content as its children
. Using this composition pattern you are giving control back to your users to compose the content of the modal however they like.
import './modal.css';
const Modal = ({ isOpen, children, ...props }) =>
isOpen ? (
<div className="overlay">
<div className="content-wrapper" {...props}>
{children}
</div>
</div>
) : null;
.overlay {
position: fixed;
background-color: rgba(0, 0, 0, 0.33);
inset: 0;
width: 100%;
z-index: 1;
}
.content-wrapper {
background-color: var(--bg-color, #fff);
border-radius: 1rem;
position: relative;
width: min(90%, 50ch);
padding: 1.5rem;
margin: 10vh auto;
}
The isOpen
prop controls whether or not the modal is open. The div with the overlay
class will be used to create a backdrop effect. And the content-wrapper
div is used to style the modal content wrapper.
Note that we are also spreading the rest of the props to this wrapper div. This will allow us to pass additional aria
attributes, e.g. aria-labelledby or aria-describedby when creating an association within the modal content (the children).
At this point, we can use our modal like:
import React, { useState } from 'react';
import Modal from "./modal";
const Example1 = (props) => {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<div>
<button onClick={open}>Open modal</button>
<Modal isOpen={isOpen}>
<p>Hi, I am the modal content 👋</p>
<button onClick={close}>Close</button>
</Modal>
</div>
);
};
Following is the preview of the example above. Click on the Open modal
to open the modal.
Modal backdrop clicks
With the bare minimum aside, let’s look at how we can automatically close the modal when a user clicks on the backdrop
—the content outside the modal.
For this, we need to understand how event bubbling works. I have written another article to explain that in more detail. But the key takeaway is:
In event bubbling the innermost target element handles the event first and then it bubbles up in the DOM tree.
In the context of our modal implementation, it means that when the user clicks inside the modal, the modal will handle the event first, and then it will bubble up towards the parent element—the backdrop
. On the contrary, if it’s outside the modal, the backdrop
element will handle it first. Here’s a great JSbin demo to visualize how the event propagates.
Now, the plan is as follows:
- First, we need to check onto which element the
click
event was dispatched. This is something we can get via the event.target property. - Then, within our
backdrop click handler
, we can use the Node.contains method to determine whetherevent.target
was a descendant of our modal element. - If that’s the case, we do nothing by returning early from the
handler
. Otherwise, we will close the modal.
It’s time to implement the plan.
// Modal with backdrop click behaviour
import React, { useRef } from "react";
import "./modal.css";
const Modal = ({ isOpen, onDismiss, children, ...props }) => {
// To capture a reference to our modal DOM instance
const dialogRef = useRef();
const handleBackdropClick = (event) => {
// return early if click region inside the dialog
if (dialogRef.current?.contains(event.target)) {
return;
}
onDismiss();
};
return isOpen ? (
<div className="overlay" onClick={handleBackdropClick}>
<div
ref={dialogRef}
className="content-wrapper"
{...props}
>
{children}
</div>
</div>
) : null;
};
export default Modal;
We have introduced a new onDismiss
function prop that is called whenever the user clicks outside the modal. Also, we have used dialogRef
to capture the modal element to invoke contains()
method as described in the plan above.
From the usage side, things would look like this:
// Example usage with backdrop click behaviour
import React, { useState } from 'react';
import Modal from "./modal";
const Example2 = (props) => {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<div>
<button onClick={open}>Open modal</button>
<Modal isOpen={isOpen} onDismiss={close}>
<p>
Hi, I am the modal content 👋.
Try clicking outside of me!
</p>
<button onClick={close}>Close</button>
</Modal>
</div>
);
};
Here’s another preview to see that behaviour in action. Try clicking outside the modal and it should close itself. Awesome 🎉!
You can also try codesandbox demo here.
Adding WAI-ARIA attributes
Now we have a Modal
that we can use with a mouse. As a developer, it’s our responsibility to make sure our UI has valid roles and aria attributes. This will make our modal screen-reader
friendly. And trust me, it’s not as hard as it sounds.
Now is the time to add some Aria
attributes as suggested by the WAI-ARIA authoring practices.
const Modal = ({ isOpen, children, ...props }) =>
isOpen ? (
<div className="overlay">
<div
className="content-wrapper"
role="dialog"
aria-modal="true"
{...props}
>
{children}
</div>
</div>
) : null;
Referring to the WAI-Aria table mentioned in WAI-ARIA guidelines for modal dialogs, we have used role
and aria-modal
attributes on our container element, which is the div with content-wrapper
class in our code above.
Role | Attribute | Element | Usage |
---|---|---|---|
dialog | div | Identifies the element that serves as the dialog container. | |
aria-modal | div | Tells assistive technologies that the windows underneath the current dialog are not available for interaction (inert). |
Moreover, we have spread the props onto the dialog
container. This pattern allows consumers of our component to pass in other attributes if required. For example, if the dialog has a title, we can either use aria-labelledby or aria-label as shown in the example below:
import Modal, { useState } from "./modal";
const Example2 = (props) => {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<div>
<button onClick={open}>Open modal</button>
<Modal isOpen={isOpen} aria-labelledby="modal-title">
<h3 id="modal-title">Modal title</h3>
<p>Hi, I am the modal content 👋,
and I am more accessible now.
</p>
<button onClick={close}>Close</button>
</Modal>
</div>
);
};
With these small changes in place, when modal receives a focus, the assistive technologies will announce this widget as a dialog
along with its title. Our modal is more inclusive now.
Wrap up
We are off to a good start in this first part. Our Modal
component is looking great and incorporates a few useful features, including mouse interaction, backdrop click, and necessary ARIA attributes.We learned about composition pattern and saw how could we handle the backdrop clidk behaviour using the event bubbling mechanism. When you are relying mostly on libraries without understanding what they do behind the scenes, you miss out on lots of things.
In our next articles in this series, we will continue our journey together and enhance our modal dialog component by adding other missing features: keyboard interaction
, focus trap
, and scroll lock
.
I hope you enjoyed reading this article. Stay tuned for future articles in this series. Thanks for reading.
Written by Amandeep Singh. Developer @ Avarni Sydney. Tech enthusiast and a pragmatic programmer.