Skip to content
WANCODE
BM

Accessible React Modal

A modal dialog built on the native <dialog> element: focus moves in and back out, Escape closes it, and the page behind is inert.

Modal.jsx
import { useEffect, useId, useRef } from "react";

export function Modal({ open, onClose, title, children }) {
  const dialogRef = useRef(null);
  const titleId = useId();

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!open || !dialog) return;

    // Remember what had focus so we can give it back
    const previouslyFocused = document.activeElement;
    dialog.showModal(); // top layer + inert background + Escape support

    return () => {
      if (dialog.open) dialog.close();
      if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus();
    };
  }, [open]);

  return (
    <dialog
      ref={dialogRef}
      className="modal"
      aria-labelledby={titleId}
      // Fires when the dialog closes for any reason (Escape, form, close())
      onClose={onClose}
      // A click whose target is the <dialog> itself landed on the backdrop
      onClick={(event) => {
        if (event.target === event.currentTarget) onClose();
      }}
    >
      <div className="modal__body">
        <h2 id={titleId}>{title}</h2>
        {children}
        <button type="button" className="modal__close" onClick={onClose}>
          Close
        </button>
      </div>
    </dialog>
  );
}
modal.css
.modal {
  padding: 0;               /* so backdrop clicks are easy to detect */
  border: 0;
  border-radius: 1rem;
  max-width: min(32rem, 100% - 2rem);
  box-shadow: 0 20px 50px rgb(0 0 0 / 0.3);
}

.modal::backdrop {
  background: rgb(0 0 0 / 0.5);
}

.modal__body {
  padding: 1.5rem;
}

/* Stop the page behind from scrolling while a modal is open */
body:has(dialog[open]) {
  overflow: hidden;
}

An accessible modal needs four things: focus moves into it when it opens, keyboard and screen-reader users can't wander behind it, Escape closes it, and focus returns to the button that opened it. Building all that by hand is error-prone. dialog.showModal() gives you the first three for free; React handles the last one in an effect cleanup.

Line by line

  • showModal() puts the dialog in the browser's top layer (above every z-index), makes everything else inert (not clickable or focusable), focuses the first focusable element inside, and closes on Escape.
  • The effect only runs when open is true. Its cleanup runs when open becomes false (or the component unmounts): it closes the dialog if needed and puts focus back on whatever was focused before.
  • onClose is the native close event. Escape closes the dialog in the browser, so we tell React about it — otherwise React's open state and the real dialog would disagree.
  • useId() creates a unique id for the heading, and aria-labelledby makes screen readers announce "Delete project, dialog" when it opens. showModal() already implies aria-modal.
  • The dialog has no padding and the content sits in .modal__body, so a click whose target is the <dialog> itself can only be on the backdrop. That's how "click outside to close" works.

Put autoFocus on the element that should get focus first (for a destructive action, usually the safe "Cancel" button). Test with only a keyboard: Tab should cycle inside the dialog, Escape should close it, and focus should land back on the opener.

Usage

JSX
import { useState } from "react";
import { Modal } from "./Modal.jsx";
import "./modal.css";

export default function App() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Delete project…
      </button>

      <Modal open={open} onClose={() => setOpen(false)} title="Delete project?">
        <p>This removes the project from this browser. It can't be undone.</p>
        <button type="button" autoFocus onClick={() => setOpen(false)}>Cancel</button>{" "}
        <button type="button" onClick={() => { console.log("deleted"); setOpen(false); }}>
          Delete
        </button>
      </Modal>
    </>
  );
}