Modal Component
by Pratik Rai
Read the full write-upBuild a modal dialog: a button opens it, and it can be dismissed three ways — the Cancel and Confirm buttons, a click on the backdrop, and the Escape key.
The styling and the open/close state are done. The dismissal behaviour is not.
Requirements
Clicking the dark backdrop closes the modal.
Clicking anywhere inside the white dialog does not close it.
Pressing Escape closes the modal, from wherever focus happens to be.
The Escape listener is removed when the modal closes, not left on the document.
The dialog is labelled by its heading (
aria-labelledby) and receives focus when it opens.
Notes
Rendering the modal into a portal is the usual production answer, but the preview has one root and no stacking-context problem, so it is out of scope here.
A full focus trap is a good follow-up question and is deliberately not required.
Hints
Modal Component (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect, useRef } from 'react';
2
3function Modal({ open, onClose, title, children }) {
4 const dialogRef = useRef(null);
5
6 // Escape is a document-level concern: the key can be pressed while focus is
7 // anywhere. The listener is added only while the modal is open, and the
8 // cleanup runs on close as well as on unmount — without it every open would
9 // leave another listener behind.
10 useEffect(() => {
11 if (!open) return undefined;
12 const onKeyDown = (event) => {
13 if (event.key === 'Escape') onClose();
14 };
15 document.addEventListener('keydown', onKeyDown);
16 return () => document.removeEventListener('keydown', onKeyDown);
17 }, [open, onClose]);
18
19 // Move focus into the dialog so the keyboard follows the eye.
20 useEffect(() => {
21 if (open) dialogRef.current?.focus();
22 }, [open]);
23
24 if (!open) return null;
25
26 return (
27 <div
28 className="backdrop"
29 // event.target is what was clicked; event.currentTarget is the backdrop
30 // itself. Comparing them is what separates "clicked the backdrop" from
31 // "clicked something inside the dialog and the event bubbled here".
32 onClick={(event) => {
33 if (event.target === event.currentTarget) onClose();
34 }}
35 >
36 <div
37 className="modal"
38 role="dialog"
39 aria-modal="true"
40 aria-labelledby="modal-title"
41 ref={dialogRef}
42 tabIndex={-1}
43 >
44 <h2 id="modal-title">{title}</h2>
45 {children}
46 <div className="actions">
47 <button className="ghost" onClick={onClose}>
48 Cancel
49 </button>
50 <button className="trigger" onClick={onClose}>
51 Confirm
52 </button>
53 </div>
54 </div>
55 </div>
56 );
57}
58
59export default function App() {
60 const [open, setOpen] = useState(false);
61
62 return (
63 <div>
64 <h1>Modal</h1>
65 <button className="trigger" onClick={() => setOpen(true)}>
66 Open modal
67 </button>
68
69 <Modal open={open} onClose={() => setOpen(false)} title="Delete project?">
70 <p>This cannot be undone. The project and its history will be removed.</p>
71 </Modal>
72 </div>
73 );
74}How it works
Two details separate a modal that works from one that only looks like it does.
The first is the backdrop click. The instinct is to stop propagation on the dialog, and it works — but it makes the dialog a black hole for every click that bubbles, which breaks unrelated listeners and analytics higher up. Comparing event.target with event.currentTarget asks the narrower question: was the backdrop itself the thing clicked, or did the click merely pass through it.
The second is the effect cleanup. document.addEventListener inside an effect without a matching removeEventListener in the returned function leaves a listener per open, and each one calls a stale onClose. Guarding the effect on open and returning the cleanup keeps exactly one listener alive, for exactly as long as the modal is.