Skip to content

UI build: accessible modal with a focus trap

A modal is a promise you test with keys: in, round, out, back, and named. A backdrop alone keeps no one in.

Predict first

Before you read: predict

ExamHall's candidate app opens this dialog from its Submit exam button. The page holds the answer editor, then that button, and the dialog renders after them. onSubmit sends the answers; the server has not replied yet.

With the keyboard only, a candidate opens the dialog, presses Tab, then Enter. The dialog shows Submitting…, and the candidate presses Tab once more.

Which element has focus now?

Code for this question
function SubmitDialog({ onClose, onSubmit }) {  const boxRef = useRef(null);  const [sending, setSending] = useState(false);   function onKeyDown(e) {    if (e.key === 'Escape' && !sending) return onClose();    if (e.key !== 'Tab') return;    const items = boxRef.current.querySelectorAll('button:not(:disabled)');    const first = items[0];    const last = items[items.length - 1];    if (e.shiftKey && document.activeElement === first) {      e.preventDefault();      last.focus();    } else if (!e.shiftKey && document.activeElement === last) {      e.preventDefault();      first.focus();    }  }   return (    <div className="backdrop">      <div ref={boxRef} role="dialog" aria-modal="true"           aria-labelledby="submit-title" onKeyDown={onKeyDown}>        <h2 id="submit-title">Submit exam?</h2>        <button autoFocus disabled={sending} onClick={onClose}>Keep working</button>        <button disabled={sending} onClick={() => { setSending(true); onSubmit(); }}>          {sending ? 'Submitting…' : 'Submit now'}        </button>      </div>    </div>  );}
Pick one

The 30-second version

A modal promises six things: focus moves in, Tab and Shift+Tab wrap at its edges, Escape closes it, the page behind is inert, focus returns to the opener, and it has a name. A hand-built trap is a key handler on the dialog, acting only while focus is inside; a disabled or removed control drops focus to <body>.

A modal is a promise you test with keys: in, round, out, back, and named. A backdrop alone keeps no one in.

How it works

Step 1 of 15

Say the contract. Focus moves in; Tab and Shift+Tab wrap; Escape closes; the page is inert; focus returns to the opener; it has a name.

Analogy: A vocal booth at a recording studio (music recording)

A singer leaves her seat on the control-room sofa to record a take.

At the studio In the build
the engineer notes which seat she left save the opener (step 10)
the booth door seals; the mixing desk is out of reach the page behind is inert (step 9)
she moves between microphone, stand and headphone hook; the engineer turns her back only at the walls the trap acts at the two edges (step 4)
"Booth A, vocals" on the door the accessible name (step 12)
a curtain across a corner of the live room a backdrop: paint, not a wall (step 3)
"stop" on the talkback, then back to the same seat Escape and close return focus (steps 11, 14)
the engineer hears her only through the booth microphone the trap hears keys only from inside (step 7)
while a take saves, the stand's lights dim but stay on a busy control keeps focus (step 8)

In real terms: a modal is a key handler at two edges, an inert page, a saved opener and a name, each checked with a key press.

Breaks down when: a booth door holds her in by itself; a trap holds no one. It only reacts, at the edges, to keys it hears, so a singer who steps out of the microphone's pickup walks straight out.

Contrasting case: a rehearsal room with its door propped open. People come and go while she sings: a non-modal panel, which needs no trap and no inert page.

Examples, run for real

4.1 ExamHall EXH-2890 and MR !3190: the kata's reference in the candidate app

EXH-2890 is the candidate app's "Submit exam?" dialog. MR !3190 replaces it with the kata's reference, and adds its 13-test keyboard list. The fake server first answers 503, then accepts the retry.

4.1 open: focus on Keep working · snapshot - dialog "Submit exam?" · axe violations in the dialog 0
4.1 Tab, Tab, Shift+Tab: Submit now > Keep working > Submit now
4.1 Enter on Submit now: focus on Submitting… · Tab, Tab, Tab: Keep working > Submitting… > Keep working · Escape: dialog still open
4.1 the server answers 503: "Could not submit. Try again." · focus stays on Keep working · Tab, Tab, Tab: Submit now > See what was saved > Keep working
4.1 Enter on Submit now again, the server accepts: dialog closed · focus on Submit exam · the answer editor takes focus again: yes

While the submission runs, aria-disabled keeps both buttons in the focus order, so Tab still turns at the edges (steps 7–8). After the 503 a link appears after Submit now; the handler reads the edges again on that key press and reaches it (step 5). When the retry succeeds, the one close path returns focus to Submit exam, and the page is no longer inert (steps 9, 11).

4.2 Payments ledger: what showModal() gives, observed

The ledger's "Post journal JE-3104?" confirm is a native <dialog> opened with showModal() from the Post… button, with no key handler.

4.2 open with showModal(): focus on Check again · the search box takes focus: no
4.2 Tab, Tab, Tab: Post > <body> (Chromium's own toolbar) > Check again
4.2 Escape: dialog closed · focus on Post…

The ledger behind never takes focus, and Escape returns it to Post… (step 2). Chromium sends the Tab after the last button to its own toolbar before the first button: native containment keeps the page out, which is not the same as wrapping (step 4). Observed in Chromium 153 only.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A payments ledger opens this dialog from its New transfer button. The page holds a search box, then that button, and the dialog renders after them.

The clerk opens the dialog with the keyboard, so focus starts in Amount, and presses Shift+Tab once.

Which element has focus now?

Code for this question
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [href], [tabindex]'; function TransferDialog({ onClose }) {  const boxRef = useRef(null);   function onKeyDown(e) {    if (e.key === 'Escape') return onClose();    if (e.key !== 'Tab') return;    const items = boxRef.current.querySelectorAll(FOCUSABLE);    const first = items[0];    const last = items[items.length - 1];    if (e.shiftKey && document.activeElement === first) {      e.preventDefault();      last.focus();    } else if (!e.shiftKey && document.activeElement === last) {      e.preventDefault();      first.focus();    }  }   return (    <div className="backdrop">      <div ref={boxRef} role="dialog" aria-modal="true"           aria-labelledby="move-title" onKeyDown={onKeyDown}>        <h2 id="move-title" tabIndex={-1}>Move funds</h2>        <input autoFocus aria-label="Amount" />        <button onClick={onClose}>Cancel</button>        <button>Send</button>      </div>    </div>  );}
Pick one

Question 2 of 8

A box office asks before it releases a fan's held seats. Its page opens this native dialog with release.showModal():

<dialog id="release">
  <h2>Release 4 seats?</h2>
  <p>They go back on sale at once.</p>
  <button autofocus>Keep seats</button>
  <button>Release</button>
</dialog>

Chromium builds the accessibility tree that a screen reader reads.

What does it expose for the open dialog?

Pick one

Question 3 of 8

A chat app confirms before it deletes a conversation. Its page opens this native dialog with dialogRef.current.showModal():

While the delete request runs, Escape must leave the dialog open. If the request fails, Delete comes back, and Escape must close the dialog again.

Which one change does both?

Code for this question
function DeleteDialog({ dialogRef, onDelete }) {  const [deleting, setDeleting] = useState(false);   async function remove() {    if (deleting) return;    setDeleting(true);    try {      await onDelete();      dialogRef.current.close();    } catch {      setDeleting(false);    }  }   return (    <dialog ref={dialogRef} aria-labelledby="del-title">      <h2 id="del-title">Delete this conversation?</h2>      <button autoFocus onClick={() => dialogRef.current.close()}>Keep it</button>      <button onClick={remove}>{deleting ? 'Deleting…' : 'Delete'}</button>    </dialog>  );}
Pick one

Question 4 of 8

MR !3191 adds a confirm dialog to the payments ledger. The ledger page holds a search box, then the Reverse… button, which opens it with <ReverseDialog opener={reverseButton} … />; the dialog renders after the page. The MR adds:

Three review comments were posted.

Which one names a real defect?

Code for this question
function ReverseDialog({ opener, onDone, onReverse }) {  const boxRef = useRef(null);   function close() {    onDone();    opener.focus();  }   function onKeyDown(e) {    if (e.key === 'Escape') return onDone();    if (e.key !== 'Tab') return;    const items = boxRef.current.querySelectorAll('button');    const first = items[0];    const last = items[items.length - 1];    if (e.shiftKey && document.activeElement === first) {      e.preventDefault();      last.focus();    } else if (!e.shiftKey && document.activeElement === last) {      e.preventDefault();      first.focus();    }  }   return (    <div className="backdrop">      <div ref={boxRef} role="dialog" aria-modal="true"           aria-labelledby="reverse-title" onKeyDown={onKeyDown}>        <h2 id="reverse-title">Reverse entry JE-2207?</h2>        <button autoFocus onClick={close}>Cancel</button>        <button onClick={() => { onReverse(); close(); }}>Reverse</button>      </div>    </div>  );}
Pick one

Question 5 of 8

the interviewer says: "Build the Submit exam? confirm for the candidate app by hand: no <dialog> element. SubmitDialog({ onClose, onSubmit }) renders after <main id="page">. onSubmit returns a promise." You have one coding round, a plain editor and React 19.

Before you type: say your keyboard test list out loud.

Name at least eight tests, each with its keys and what it expects.

Question 6 of 8

the interviewer says: "Build the Submit exam? confirm for the candidate app by hand: no <dialog> element. SubmitDialog({ onClose, onSubmit }) renders after <main id="page">. onSubmit returns a promise." You have one coding round, a plain editor and React 19.

Now write it, from a blank file that defines SubmitDialog.

Question 7 of 8

A payroll app closes each pay month from a summary card. March's card shows its payslip count and a Close March… button, which opens this dialog; React renders the dialog after the page.

After Escape, focus is on <body>, not on Close March…. The team wants focus on Keep open when the dialog opens, and back on Close March… after Escape.

Which change does both?

Code for this question
function ClosePeriodDialog({ onClose }) {  const openerRef = useRef(null);  const keepRef = useRef(null);   useEffect(() => {    openerRef.current = document.activeElement;    return () => openerRef.current.focus();  }, []);   function onKeyDown(e) {    if (e.key === 'Escape') onClose();  }   return (    <div className="backdrop">      <div role="dialog" aria-modal="true" aria-labelledby="cp-title" onKeyDown={onKeyDown}>        <h2 id="cp-title">Close March?</h2>        <button ref={keepRef} autoFocus onClick={onClose}>Keep open</button>        <button>Close period</button>      </div>    </div>  );}
Pick one

Question 8 of 8

A streaming studio's page holds the viewers' message box and the End stream… button. End stream… opens a confirm, "End the live stream?", with Keep streaming and End and delete recording… buttons; End and delete recording… opens a second confirm, "Delete the recording too?", with Keep recording and Delete. The studio builds its dialogs by hand, with no <dialog> element: both confirms use this box, the second rendered from inside the first with createPortal into document.body, and the first button of each takes focus when it opens.

The team wants Escape in the second confirm to close only it, with focus back on End and delete recording…, and a second Escape to close the first, with focus back on End stream….

Which body of onKeyDown does that?

Code for this question
function ConfirmBox({ title, onClose, children }) {  const [opener] = useState(() => document.activeElement);  useEffect(() => () => opener.focus(), [opener]);   function onKeyDown(e) {    if (e.key === 'Escape') onClose();  }   return (    <div className="backdrop">      <div role="dialog" aria-modal="true" aria-label={title} onKeyDown={onKeyDown}>        {children}      </div>    </div>  );}
Pick one

Go deeper

Pro tips
  1. Offer native <dialog> with showModal() first, then explain what you would build by hand if it were banned. When: the interviewer asks you to build a modal (steps 2, 3). Verify (RAN, tips.mjs): the same ledger page and dialog markup, native and hand-built with no trap and no inert page, each given eight Tabs from its first button:
    tip 1 native <dialog> + showModal(): 8 Tabs from Check again land on the ledger 0 times
    tip 1 styled div, no key handler, no inert page: 8 Tabs from Check again land on the ledger 3 times
    (The curriculum row's tip, [SELF].)
  2. Run your own build against the keyboard list before you call it done: KATA_IMPL=./my-dialog.jsx node --test kata/kata.test.mjs. When: practising the kata at home, against the cases the reference passes (step 14). Verify (RAN, tips.mjs):
    tip 2 KATA_IMPL=./kata/reference.jsx: exit 0 · tests 13 · pass 13 · fail 0
    tip 2 KATA_IMPL=<temp>/escape-always-closes.jsx (Escape closes while the submission runs): exit 1 · tests 13 · pass 12 · fail 1 · red: K10
Source

Guides and references change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed in Chromium 153.

  • W3C WAI ARIA Authoring Practices Guide, Dialog (Modal) Pattern (the curriculum row's first source), https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ (apg-dialog-modal.txt): "If focus is on the last tabbable element inside the dialog, moves focus to the first tabbable element inside the dialog"; "Escape: Closes the dialog"; "When a dialog closes, focus returns to the element that invoked the dialog"; a dialog has "A value set for the aria-labelledby property that refers to a visible dialog title"; for a step "that is not easily reversible", "it may be advisable to set focus on the least destructive action"; and mark a dialog modal only when "Application code prevents all users from interacting in any way with content outside of it" (steps 1, 3, 4, 11, 12). As of 2026-10-04.
  • MDN, <dialog>: The Dialog element (the row's second source), https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog, served from its new path …/Reference/Elements/dialog (mdn-dialog.txt): "Modal dialogs can also be closed by pressing the Esc key"; "If multiple modal dialogs are open, pressing the Esc key should close only the last shown dialog. When using <dialog>, this behavior is provided by the browser."; "If the dialog is closed by pressing the Esc key or the requestClose() button, a cancel event gets triggered first, giving code a chance to prevent closure in some cases"; and "the native <dialog> element provides usability and accessibility features that must be replicated if you use other elements for a similar purpose" (steps 2, 13, 15). As of 2026-10-04.
  • MDN, inert (the row's third source), https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert, served from …/Reference/Global_attributes/inert (mdn-inert.txt): "Modal <dialog>s generated with showModal() escape inertness" and "No other element can escape inertness" (step 9). As of 2026-10-04.
  • MDN, aria-disabled, https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled (mdn-aria-disabled.txt): "aria-disabled does not change the focusability of such elements", and it fits "A button which is important to keep in the page's focus order, but its action is presently unavailable" (step 8). As of 2026-10-04.

React's own reference names no time at which autoFocus acts; step 10's order (autoFocus during the commit, before the dialog's effects) is executed, not cited.

Checked against the tools and docs as of 2026-10-04.