skip to content

When a modal dialog opens on a page, what must happen to keyboard focus while it is open and when it closes?

level: seniorimportance: must knowfreq 56%

answer

  1. four obligations, not one
  2. where focus goes on open
  3. what the background must become
  4. the trigger has to get it back
  5. showModal does it all for you

basics

~20 s

Move focus into the dialog when it opens, keep it inside while it is open, make the rest of the page inert so Tab cannot escape, and return focus to the control that opened the dialog once it closes.

solid answer

~40 s

There are four obligations. First, move focus into the dialog on open — usually the first meaningful control, or the dialog container itself carrying `tabindex="-1"` so the title is read out. Second, constrain focus: Tab from the last focusable element must wrap to the first, and nothing behind the overlay may be reachable. Third, neutralise the background, which means `inert` on the rest of the document — `aria-hidden` alone leaves the content clickable and Tab-reachable. Fourth, on close, restore focus to the element that opened the dialog, and if that element is gone, pick a sensible nearby target rather than letting focus fall to `<body>`. A `<dialog>` opened with `showModal()` gives you all four for free, plus Escape to dismiss, which is the main argument for using it instead of a hand-rolled overlay.

code

html · 14 lines
html
<button id="open">Delete file</button>

<dialog id="confirm">
  <h2>Delete report.pdf?</h2>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('confirm');
  document.getElementById('open').addEventListener('click', () => dialog.showModal());
</script>

go deeper

for a junior

Know that opening a dialog means moving focus into it and giving it back afterwards, and that <dialog> with showModal() exists and handles this for you.

for a middle

Explain all four obligations and what showModal() supplies for each, including why the background needs inert rather than aria-hidden and where initial focus should land.

for a senior

Demonstrate the diagnosis and the edge cases: focus escaping behind the overlay, a trigger removed while the dialog is open, dynamically added controls invalidating a trap computed at open time. Say how you would test it with the keyboard alone.

for a principal

Argue the build-versus-native decision across a product: one audited dialog primitive that every team consumes, what you accept when native <dialog> styling or support constrains you, and how you keep bespoke overlays from reappearing in feature code.

## Why focus is the whole problem A modal is a visual convention: an overlay covers the page and the eye accepts that only the dialog is live. Keyboard and screen-reader users get none of that for free. Focus is still wherever it was — typically on the button that opened the dialog, which now sits behind the overlay. Press Tab and you walk into content you cannot see; a screen reader happily reads the page underneath. "Modal" is a promise about interaction, and markup has to keep it. ## The four obligations **Move focus in.** On open, focus must land inside the dialog. Nothing else makes the dialog the user's context. **Constrain it.** While open, Tab and Shift+Tab must cycle within the dialog. Focus must not reach page content, and ideally not the browser chrome round-trip either. **Neutralise the background.** Content behind the dialog should be non-interactive and absent from the accessibility tree. The `inert` attribute does both. **Restore it.** On close, focus returns to the element that opened the dialog, so the user resumes exactly where they left off. ## What the native dialog gives you `<dialog>` opened with `showModal()` satisfies all four. The browser makes the rest of the document inert, so background content is neither focusable nor exposed to assistive technology and Tab is naturally confined. It moves focus into the dialog: to the element carrying `autofocus` if there is one, otherwise the first focusable element inside, otherwise the dialog itself. Escape fires a `cancel` event and closes it. When it closes, the browser returns focus to the element that was focused before it opened. ```html <button id="open">Delete file</button> <dialog id="confirm"> <h2>Delete report.pdf?</h2> <p>This cannot be undone.</p> <form method="dialog"> <button value="cancel" autofocus>Cancel</button> <button value="delete">Delete</button> </form> </dialog> ``` Note that `show()` — the non-modal opener — does none of this: the rest of the page stays interactive and focus is not confined. Non-modal is a different component with different rules. ## Choosing where focus lands The default choice is the first interactive control in the dialog. Two adjustments matter in practice. If the dialog is content-heavy, focus a container or heading with `tabindex="-1"` instead, so the accessible name of the dialog is announced before the user starts tabbing; jumping straight to a control skips the title. And never put initial focus on a destructive action — `autofocus` belongs on Cancel, not on Delete, because Enter is pressed reflexively. ## Restoring focus, including the awkward case The trigger sometimes disappears: you opened a confirmation from a row's Delete button and the row is now gone. Falling back to `<body>` is the failure mode you are trying to avoid, because the next Tab restarts at the top of the document. Pick a stable target — the list container, the heading above it, the table — give it `tabindex="-1"`, and focus that instead. ## When you hand-roll one If you cannot use `<dialog>`, you are reimplementing all four obligations. The container needs `role="dialog"` with `aria-modal="true"` and an accessible name, the rest of the page needs `inert`, Tab must wrap from the last focusable element back to the first, and the dialog must be dismissible. Enumerating "all focusable elements" is itself unreliable: elements can be added while the dialog is open, disabled mid-flight, or hidden by CSS, so a trap computed once at open time drifts. ## Testing it Open the dialog with the keyboard alone. Focus should be inside immediately. Hold Tab and count — the ring must cycle and never appear behind the overlay. Press Escape. Focus should be back on the button you started from, and the next Tab should continue from there. Every failure in that sequence is a real bug for a real user, and none of them are visible with a mouse.

  • Where should initial focus land if the dialog's primary action is destructive?
    Not on the destructive button. Put `autofocus` on Cancel, or focus the dialog container with `tabindex="-1"` so the heading is announced first. Users press Enter reflexively on an opening dialog, and a focused Delete turns that reflex into data loss.
  • What do you do when the element that opened the dialog no longer exists at close time?
    Choose a stable fallback near where the trigger was — the list, table, or section heading — and give it `tabindex="-1"` so you can focus it. The one outcome to avoid is focus reverting to `<body>`, which restarts tabbing at the top of the page.
  • How does a <dialog> opened with show() rather than showModal() differ for focus?
    `show()` is non-modal: the rest of the page stays interactive and exposed, and focus is not confined to the dialog. It is the right call for a non-blocking panel, but it means you must not describe it as a modal or set `aria-modal="true"` on it.

saying these in an interview costs you the question

  • Says aria-hidden on the background is enough to block interaction
  • Assumes the overlay visually covering the page also blocks Tab
  • Never returns focus to the trigger after closing
  • Puts initial focus on the destructive confirm button
  • Traps focus with no Escape or close path, stranding the user

context