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.
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 {
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 everyz-index), makes everything else inert (not clickable or focusable), focuses the first focusable element inside, and closes on Escape.- The effect only runs when
openis true. Its cleanup runs whenopenbecomes false (or the component unmounts): it closes the dialog if needed and puts focus back on whatever was focused before. onCloseis the nativecloseevent. Escape closes the dialog in the browser, so we tell React about it — otherwise React'sopenstate and the real dialog would disagree.useId()creates a unique id for the heading, andaria-labelledbymakes screen readers announce "Delete project, dialog" when it opens.showModal()already impliesaria-modal.- The dialog has no padding and the content sits in
.modal__body, so a click whosetargetis 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
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>
</>
);
}