DEV Community

Cover image for Build an Accessible Modal, Then Explain the Trade-offs in an Interview
Karuha
Karuha

Posted on • Originally published at aceround.app

Build an Accessible Modal, Then Explain the Trade-offs in an Interview

Most modal interview answers stop at “I’d add role="dialog".” That is not enough. A modal is a keyboard contract: focus enters predictably, cannot fall behind the overlay, Escape has a defined outcome, and focus returns to the element that opened it.

The good news is that modern HTML gives us a strong baseline. Start with native <dialog>, then be ready to explain what it does, what you still own, and how you test it.

A frontend developer interview preparation workspace

What makes a modal accessible in practice?

For a modal dialog, four moments matter more than a long list of ARIA attributes:

Moment Expected behavior Why an interviewer cares
Open Focus moves into the dialog A keyboard user gets context immediately
Navigate Tab and Shift+Tab stay within the modal Focus must not reach hidden page controls
Dismiss Escape or an explicit close control works Users need a reliable exit
Close Focus returns to the opener The user does not lose their place

The WAI-ARIA Authoring Practices modal dialog pattern describes the same lifecycle. The useful interview move is to narrate it as state restoration, not “accessibility polish”: opening a modal temporarily changes where the user can operate; closing it restores that prior context.

Start with a small native implementation

This is a complete example you can save as dialog.html and open in a current browser. It deliberately avoids a hand-rolled focus trap.

<button id="open-settings">Project settings</button>

<dialog id="settings-dialog" aria-labelledby="settings-title">
  <form method="dialog">
    <h2 id="settings-title">Project settings</h2>
    <p>Changes are saved when you confirm.</p>

    <label>
      Project name
      <input name="projectName" value="Roadmap" autofocus />
    </label>

    <menu>
      <button value="cancel">Cancel</button>
      <button value="save">Save changes</button>
    </menu>
  </form>
</dialog>

<script>
  const opener = document.querySelector("#open-settings");
  const dialog = document.querySelector("#settings-dialog");
  let returnFocusTo = null;

  opener.addEventListener("click", () => {
    returnFocusTo = document.activeElement;
    dialog.showModal();
  });

  dialog.addEventListener("close", () => {
    returnFocusTo?.focus();
  });
</script>
Enter fullscreen mode Exit fullscreen mode

showModal() is doing important work here. A modal native dialog is presented above the page, and browsers treat the rest of the document as unavailable for interaction while it is open. The element with autofocus provides an intentional first landing spot; without one, the browser chooses an appropriate focusable descendant.

The form method="dialog" detail is also useful in a take-home. It gives both buttons a simple close path without pretending that “Save” has completed persistence. In production, I would validate and save first, then call dialog.close() only on success.

Why not write the focus trap yourself?

You can. You will also inherit a surprising number of edge cases: no focusable descendants, dynamically disabled buttons, nested dialogs, portal boundaries, shadow DOM, and what happens when the opener unmounts before close.

A custom dialog may be justified when the product needs animation control or a design system API that native <dialog> cannot provide. But “we have always used a div overlay” is not a reason. Native <dialog> gives a smaller surface area, which means less code to test.

That is the trade-off I would state out loud:

I would start with native <dialog> because it provides modal semantics and browser-managed focus containment. If our component requirements force a custom primitive, I would use a well-tested dialog library rather than reimplement keyboard behavior. In either case, I would test the lifecycle, not just the visual overlay.

The MDN <dialog> reference is a good place to check the browser behavior and styling details before committing to the choice.

Make the interaction testable before you style it

Here is a five-minute manual test pass that catches most weak modal implementations:

  1. Reload the page and use only the keyboard.
  2. Tab to Project settings and press Enter.
  3. Confirm focus lands in the dialog, on the project-name field.
  4. Tab through every control, then use Shift+Tab backwards. Focus should never land on the page behind the dialog.
  5. Press Escape. Focus should return to Project settings.
  6. Reopen it and activate Cancel. The same return behavior should hold.

If the team uses Playwright, the important assertions are equally small:

await page.getByRole("button", { name: "Project settings" }).click();

await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByLabel("Project name")).toBeFocused();

await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Project settings" })).toBeFocused();
Enter fullscreen mode Exit fullscreen mode

Notice what is absent: a screenshot assertion. Visual tests help, but they do not tell you whether a keyboard user can leave a destructive confirmation safely.

How to turn this into a senior-level answer

Interviewers often follow the code with one of these questions:

  • “Where should initial focus go for a destructive dialog?”
  • “What happens if the opening button disappears after save?”
  • “Would you support nested modals?”
  • “How would this work with a React portal?”

A strong answer does not claim one universal rule. For a destructive confirmation, focus the least destructive action when that prevents an accidental confirm. If the original opener no longer exists at close time, send focus to the nearest sensible heading or the next logical control, not document.body. For nested dialogs, avoid them if the flow can be redesigned; each additional layer makes context recovery harder.

For React, the same lifecycle applies. A portal changes DOM placement, not the user expectation: save the opener, move focus on open, contain it while modal, restore it on close. The component API should make those behaviors defaults rather than asking every caller to remember them.

A practical rehearsal prompt

After implementing the example, give yourself 90 seconds to explain it without reading the code:

“I used native dialog so the browser owns the modal baseline. On open, I preserve the active element and send focus to a meaningful control. I test forward and reverse tabbing plus Escape. On close, I restore focus unless the original control has been removed, in which case I choose the next stable landmark. If native dialog did not meet our design-system needs, I would take a tested primitive rather than rebuild focus management.”

That is a much better practice target than memorizing ARIA roles. If you want someone to keep asking the follow-ups while you rehearse, aceround.app — AI interview assistant can act as a practice partner; use it to pressure-test your explanation, not to manufacture experience.

FAQ

Do I need role="dialog" on native <dialog>?

No. Native dialog already exposes dialog semantics. Add an accessible name with aria-labelledby or aria-label.

Should every modal focus the first button?

No. Focus the element that best explains the task. A form often starts at its first input; a long confirmation may focus a static heading so a screen reader can read the message first.

Is Escape always required?

It is a conventional and expected dismissal route for a modal. Do not make it the only route: always provide a visible close or cancel action.

Can I use this exact code in production?

Use it as a baseline, then test with your supported browsers, screen readers, and product flows. Modal behavior is a user-facing contract, not a copy-paste checkbox.


Sources: WAI-ARIA Authoring Practices modal dialog pattern and MDN’s <dialog> reference, linked above. AI assisted with outlining and editing; the code, interaction checks, and final technical review were human-verified.

Top comments (0)