Aman Explains

Settings

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.

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:

  1. First, we need to check onto which element the click event was dispatched. This is something we can get via the event.target property.
  2. Then, within our backdrop click handler, we can use the Node.contains method to determine whether event.target was a descendant of our modal element.
  3. 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.

RoleAttributeElementUsage
dialogdivIdentifies the element that serves as the dialog container.
aria-modaldivTells 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.


Amandeep Singh

Written by Amandeep Singh. Developer @  Avarni  Sydney. Tech enthusiast and a pragmatic programmer.