React Modal Component with HTML Dialog

August 23rd, 2023
Tent beside snowcapped mountain

Today I would like to show you what I have found to be the best way to implement a modal component in React. But first, let's start by defining what exactly a modal is and how it is different from a dialog.

Modal vs Non-Modal Dialogs

A modal is a pop-up element on a webpage that prohibits interacting with any part of the page outside of the modal. It is often used when it is necessary for a user to address or acknowledge something. If you've ever tried to delete something on a website, you might have seen a modal appear that says, “Are you sure you want to delete this?”, and had to click a confirm button to proceed.

A non-modal dialog on the other hand, is a popup that does not block user interactions with the rest of the page. They are often used for alerts or notifications, like cookie consent or help dialogs. Today we are only going to look at implementing a true Modal.

Why Modals are Difficult

Creating a good modal component can be tricky. One thing you need to take into consideration is how to “trap” the user's focus. To maintain accessibility standards, a website should be navigable by keyboard. When the user presses the tab key, the focus will shift to the next interactive element. However, when a modal is open, the user should only be able to tab to interactive elements within the modal and not any other part of the page. This is what is meant by “focus trapping”.

In addition to focus trapping, there are many other things that must be taken into consideration. The modal should be closable by pressing the escape key, it should have the appropriate aria attributes, and it should render everything outside of the modal inert.

Because of these complexities, any time a React developer has needed to implement a modal, we've often reached for libraries that have already solved this problem. This is not an ideal solution though as this requires adding a dependency to your project and usually involves overwriting CSS to finagle the Modal to look and behave like you want it to… Wouldn't it be nice if there was a native HTML element that could do this for us?

The Dialog Element

Well there is! The dialog element can be used to create both modal and non-modal dialogs. At the time of writing this, the dialog element is supported by about 93% of users' browsers globally. And the beauty of this element is that it handles all the tricky bits for us - it traps focus, includes the correct aria attributes, and just generally provides an accessible element that works across browsers.

Now let's look at how we can use this dialog element to create a reusable React component.

Building a React Modal Component

If you would like to see the end result of the modal we will be building, feel free to refer to my codepen.

Let's start by creating a simple functional component that just returns a dialog element.

const Modal = ({ children }) => {
  return <dialog>{children}</dialog>;
};

This will take whatever is passed into the Modal component via the children prop and display it within the modal.

This looks good so far, but now we need some way to open and close the modal. To open the dialog element, you should use either the show(), or showModal() instance methods. You should use show() if the dialog is intended to be non-modal, and showModal() if the dialog element is intended to be a modal (This is what we'll use). To close the modal, you can use the close() method… Here's how we'll control whether the modal is open by a simple prop:

import { useEffect, useRef } from "react";

const Modal = ({ showModal, children }) => {
  const modalRef = useRef();

  useEffect(() => {
    if (!modalRef.current) return;

    if (showModal) {
      modalRef.current.showModal();
    } else {
      modalRef.current.close();
    }
  }, [showModal]);

  return <dialog ref={modalRef}>{children}</dialog>;
};

Here, we have added a boolean showModal prop that will control whether the modal is open or closed. We've added a useEffect that will run any time the value of this prop changes. So if the prop is changed to true, it will run the useEffect function and open the modal, and if the prop is changed to false, it will run the useEffect function and close the modal. We have to use a ref here in order to directly access the instance method of the dialog element.

There is just one big problem with this component. The showModal prop should dictate whether or not the modal is open, however it is possible to open the modal and then close it with the escape key without ever explicitly setting the showModal prop to false. This is because the dialog element's default behavior is to close itself if the escape key is pressed. In order to address this, let's add a setter prop that we can explicitly call whenever the modal is closed.

import { useEffect, useRef } from "react";

const Modal = ({ showModal, setShowModal, children }) => {
  const modalRef = useRef();

  useEffect(() => {
    if (!modalRef.current) return;

    if (showModal) {
      modalRef.current.showModal();
    } else {
      modalRef.current.close();
    }
  }, [showModal]);

  return (
    <dialog ref={modalRef} onClose={() => setShowModal(false)}>
      {children}
    </dialog>
  );
};

And that's all there is to it! Now we can add a modal wherever we want, and place anything inside it like so:

import { useState } from "react";

import Modal from "./modal.js";

const MyApp = () => {
  const [showModal, setShowModal] = useState(false);

  return (
    <>
      <button onClick={() => setShowModal(true)}>Open Modal</button>

      <Modal showModal={showModal} setShowModal={setShowModal}>
        <p>Put any content inside the modal here!</p>
        <button onClick={() => setShowModal(false)}>Close</button>
      </Modal>
    </>
  );
};

This component is super flexible and can be used to show anything you want inside a modal. It is also really easy to style as you can style it the same way you would any other native HTML element.

Another great thing about the dialog element is its use with the ::backdrop pseudo-element that can be used to style the page behind the modal. In my case, if I wanted to blur everything on the page behind the modal, I could do it simply using this selector.

dialog::backdrop {
  backdrop-filter: blur(3px);
}

And there you have it! If you would like to see my code with a demo of the modal, checkout my codepen!