Build a modal dialog
mediumAccessibility+200 XP
Build a modal that opens from a button.
It should close three ways: an explicit close button, pressing Escape, and clicking the backdrop behind it — but clicking inside the modal's own content must never close it.
Examples
// Click <button data-testid="open-modal">
// → <div role="dialog" aria-modal="true"> appears,
// wrapped in a backdrop.
// Press Escape → dialog closes.
// Click backdrop → dialog closes.
// Click dialog body → nothing happens.
// Click close btn → dialog closes.Four open/close paths; the body-click is the one that must NOT bubble into a close.
useEffect(() => {
if (!open) return;
function onKey(e) {
if (e.key === "Escape") setOpen(false);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// On the dialog body:
<div role="dialog" onClick={(e) => e.stopPropagation()}>The Escape listener is scoped to the open state; `stopPropagation` on the dialog guards against the backdrop handler.
What it has to do
- A button `data-testid="open-modal"` opens the modal.
- While open, an element with `role="dialog"` is present in the DOM; while closed, it is not present at all (unmounted, not just hidden).
- A button `data-testid="close-modal"` inside the dialog closes it.
- Pressing Escape while the modal is open closes it.
- A `data-testid="modal-backdrop"` element behind the dialog closes it when clicked directly — but a click on the dialog content itself must not propagate into a close.
Notes
- Trap focus inside the dialog while it's open (first/last focusable loop), and restore focus to the trigger button on close — screen reader + keyboard users rely on it.
- Lock body scroll with `document.body.style.overflow = "hidden"` while open; otherwise iOS and desktop users can scroll the page behind a mounted modal.
- `role="dialog"` + `aria-modal="true"` is the minimum. Give the dialog an accessible name with `aria-labelledby` pointing to the heading inside it.
- A native `<dialog>` element now handles most of this for free (`showModal`, focus trap, Escape, backdrop pseudo-element) — reach for it before building bespoke in new code.
Stuck? The react course covers the ideas this problem is built on.
import { useState } from "react"; export default function App() { const [open, setOpen] = useState(false); // TODO: pressing Escape while the modal is open should also close it. // (Closing via the button and the backdrop already works below.) return ( <div style={{ fontFamily: "sans-serif", padding: 16 }}> <button data-testid="open-modal" onClick={() => setOpen(true)}> Open modal </button> {open ? ( <div data-testid="modal-backdrop" onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "grid", placeItems: "center", }} > <div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()} style={{ background: "white", padding: 24, borderRadius: 8, minWidth: 240 }} > <p>Modal content</p> <button data-testid="close-modal" onClick={() => setOpen(false)}> Close </button> </div> </div> ) : null} </div> ); }
- 1.A button `data-testid="open-modal"` opens the modal.
- 2.While open, an element with `role="dialog"` is present in the DOM; while closed, it is not present at all (unmounted, not just hidden).
- 3.A button `data-testid="close-modal"` inside the dialog closes it.
- 4.Pressing Escape while the modal is open closes it.
- 5.A `data-testid="modal-backdrop"` element behind the dialog closes it when clicked directly — but a click on the dialog content itself must not propagate into a close.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
5 tests · +200 XP
Discussion
Sign in to postSign in to join the conversation.Sign in
No comments yet. Be the first to say something.