A form in a modal dialog breaks keyboard users in three predictable ways: Tab escapes the dialog into the page behind it, a validation error inside the dialog moves focus nowhere useful, and closing the dialog drops focus at the top of the document, so the user has to tab through the whole page to get back to where they were.

The native <dialog> element opened with showModal() now handles most of the hard parts — it makes the rest of the page inert, puts the dialog in the top layer, and closes on Escape. What remains is form-specific: choosing initial focus, routing validation errors inside the dialog, handling Escape when there are unsaved changes, and returning focus. This page, part of focus management after validation, covers each.


Context and prerequisites

What dialog.showModal() gives you:

  • Top layer rendering — no z-index battles.
  • Inert background — everything outside the dialog is removed from focus order and the accessibility tree, so Tab and screen-reader navigation stay inside. No hand-written focus trap is needed.
  • Escape closes — firing a cancel event first (which you can prevent), then close.
  • Initial focus — the first focusable element, or the element with autofocus inside the dialog.
  • <form method="dialog"> — a form that closes the dialog on submit and sets dialog.returnValue to the submitter’s value, without a network request.

Browsers restore focus to the previously focused element when a modal dialog closes in current implementations, but behaviour has varied historically; restoring explicitly is cheap insurance.

Native dialog versus a div-based modal With a native dialog and showModal, focus containment and an inert background are built in, Escape fires cancel and close, initial focus goes to the first focusable or autofocus element, and focus returns to the trigger in current browsers. With a div-based modal, each of these must be implemented by hand with a focus trap script, aria-hidden or inert on siblings, key listeners and manual focus restoration. Behaviour dialog + showModal() div-based modal focus stays inside built in (inert page) focus-trap script background hidden from AT built in inert / aria-hidden by hand Escape closes cancel → close key listener initial focus first focusable / autofocus manual focus returns to trigger current browsers; restore anyway manual

The core pattern: a native dialog with a guarded form

<button type="button" id="edit-address">Edit address</button>

<dialog id="address-dialog" aria-labelledby="address-title">
  <form id="address-form" novalidate>
    <h2 id="address-title" tabindex="-1">Edit delivery address</h2>
    <div id="address-summary" class="error-summary" tabindex="-1" role="group"
         aria-labelledby="address-summary-title" hidden></div>
    <label for="line1">Address line 1</label>
    <input id="line1" name="line1" autocomplete="address-line1" required>
    <label for="postcode">Postcode</label>
    <input id="postcode" name="postcode" autocomplete="postal-code" required>
    <div class="actions">
      <button type="submit">Save address</button>
      <button type="button" data-action="cancel">Cancel</button>
    </div>
  </form>
</dialog>
export function wireModalForm(
  trigger: HTMLButtonElement,
  dialog: HTMLDialogElement,
  validate: (f: HTMLFormElement) => { field: string; message: string }[],
  save: (data: FormData) => Promise<void>,
  renderSummary: (errors: { field: string; message: string }[]) => void,
) {
  const form = dialog.querySelector("form")!;
  let dirty = false;
  let returnTo: HTMLElement | null = null;

  trigger.addEventListener("click", () => {
    returnTo = document.activeElement as HTMLElement;
    dirty = false;
    dialog.showModal();
    // Initial focus: the first field for short, obvious forms. For forms that
    // need context first, focus the heading (tabindex=-1) instead.
    dialog.querySelector<HTMLInputElement>("input")?.focus();
  });

  form.addEventListener("input", () => { dirty = true; });

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const errors = validate(form);
    if (errors.length) {
      renderSummary(errors);
      // Focus stays INSIDE the dialog: the summary at its top.
      dialog.querySelector<HTMLElement>("#address-summary")!.focus();
      return;
    }
    await save(new FormData(form));
    dirty = false;
    dialog.close("saved");
  });

  const tryClose = () => {
    if (dirty && !confirm("Discard your changes to the address?")) return false;
    dialog.close("cancelled");
    return true;
  };
  form.querySelector("[data-action=cancel]")!.addEventListener("click", tryClose);

  // Escape fires 'cancel' first: intercept it when there are unsaved changes.
  dialog.addEventListener("cancel", (e) => { e.preventDefault(); tryClose(); });

  dialog.addEventListener("close", () => {
    // Explicit restore: land back on the trigger (or whatever opened the dialog).
    (returnTo ?? trigger).focus();
  });
}

Step-by-step walkthrough

  1. Use <dialog> with showModal(). It removes the need for a JavaScript focus trap and hides the background from assistive technology. Label it with aria-labelledby pointing at the heading.
  2. Record where focus was before opening. Usually the trigger button; sometimes a row action in a table. Restore to it on close.
  3. Choose initial focus deliberately. For a short form whose purpose is obvious from the trigger (“Edit address”), focus the first field. For longer or unexpected dialogs, focus the heading so users hear the context first.
  4. Keep validation errors inside the dialog. Render the error summary at the top of the dialog and focus it; its links move to fields within the dialog, per building an accessible error summary.
  5. Guard Escape and Cancel when dirty. Prevent the cancel event and confirm discarding changes — the dialog-sized version of warning before leaving a form with unsaved changes.
  6. Announce the outcome after closing. “Address saved” through the page announcer, because focus returns to the trigger and the dialog’s own messages are gone.

Why initial focus is a content decision

There is no single correct first focus. Focusing the first input saves a keystroke and suits dialogs whose purpose the user just chose (“Edit address”, “Add a note”). But when the dialog explains something first — a warning, terms, a summary of what will change — focusing the first input skips the explanation for screen-reader users, who hear only “Postcode, edit text”. Focusing the heading (made focusable with tabindex="-1") reads the dialog’s title and lets users continue into the content in order. Decide per dialog based on whether the user needs context before acting, and be consistent across similar dialogs.

Open, fail validation, fix, save, return The user activates Edit address. The dialog opens modally with the page behind it inert, and focus goes to address line 1. The user submits with an empty postcode; the summary at the top of the dialog lists the error and receives focus. The user follows the link, enters the postcode and saves. The dialog closes, focus returns to the Edit address button, and the announcer says address saved. User Dialog Form Page Edit address showModal(); page inert; focus line 1 submit (postcode empty) summary shown + focused (inside dialog) fix postcode; save close → focus trigger; "Address saved"

Failure modes and edge cases

1. Div-based modals without inert background

A custom modal that only traps Tab still lets screen-reader users browse the page behind it with the virtual cursor. Use inert on the rest of the page (or <dialog>), not just a key listener.

2. Focus lost when the trigger disappears

If saving removes or re-renders the trigger (for example, the row it belonged to), restoring focus to it fails silently. Fall back to a sensible container — the updated row, the list heading — made focusable with tabindex="-1".

3. Scroll locking

showModal() does not stop the page behind from scrolling on all browsers. Add overflow: hidden to the root while a modal is open if background scroll is distracting, and remove it on close.

4. Nested dialogs

Opening a confirmation dialog from inside a form dialog works with native dialogs — each showModal() stacks in the top layer. Return focus to the element inside the first dialog when the second closes.

5. Mobile viewport height

Long forms in dialogs overflow on small screens. Make the dialog content scrollable (max-height: 100dvh with overflow: auto on the form), and keep the heading and actions visible, so focused fields are not hidden behind them.

Four focus moments in a modal form On open, focus goes to the first field or the heading, depending on whether context is needed first. On validation failure, focus goes to the error summary inside the dialog. On cancel with unsaved changes, focus goes to a confirmation, and back into the form if the user keeps editing. On close, focus returns to the element that opened the dialog, with an announcement of the outcome. Open First field, or heading if context needed. Invalid submit Summary inside the dialog. Cancel with changes Confirm discard. Keep editing → back in form. Close Back to the trigger. Announce the outcome.

Verification checklist


Frequently Asked Questions

Do I still need a focus-trap library?

Not with <dialog> and showModal() in current browsers — the inert background contains focus. Libraries remain useful for non-modal patterns (drawers that should trap focus without being dialogs) or older browser support.

Should forms go in modals at all?

Short, self-contained edits (one address, a note) work well in dialogs. Long or multi-step forms are better as pages: they can be bookmarked, survive reloads, and do not fight small screens. If a modal form keeps growing, move it to a page.

Is `method="dialog"` useful for real forms?

It closes the dialog and sets returnValue without submitting to the server, which suits confirm dialogs and client-only choices. For forms that save data, handle submit yourself so you can validate and show errors before closing.


Related

← Focus Management After Validation