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
cancelevent first (which you can prevent), thenclose. - Initial focus — the first focusable element, or the element with
autofocusinside the dialog. <form method="dialog">— a form that closes the dialog on submit and setsdialog.returnValueto 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.
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
- Use
<dialog>withshowModal(). It removes the need for a JavaScript focus trap and hides the background from assistive technology. Label it witharia-labelledbypointing at the heading. - Record where focus was before opening. Usually the trigger button; sometimes a row action in a table. Restore to it on close.
- 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.
- 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.
- Guard Escape and Cancel when dirty. Prevent the
cancelevent and confirm discarding changes — the dialog-sized version of warning before leaving a form with unsaved changes. - 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.
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.
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.