Focus-Trapping a Validation Error Summary Dialog
When a long form fails submission and you surface every problem inside a modal error summary, keyboard and screen-reader users must not be able to Tab out of that dialog into the still-broken form behind it — this recipe wires a focus trap around the summary and returns focus cleanly on dismissal, layered on top of the native Constraint Validation API.
When to Use This Modal-Summary Focus Trap
A focus-trapped summary dialog is the heaviest of the error-delivery options, so reach for it deliberately. Use it when the form is long enough that an inline list scrolls off-screen, when submission is a discrete “commit” action, or when you need to force acknowledgement before the user retries.
- Prefer a non-modal inline summary — see Managing Focus After Validation Failure — when the form is short and the user can see all fields at once.
- Prefer a toast for transient, non-blocking notices; the trade-offs are laid out in When to Use a Toast vs an Inline Error.
- Use this modal trap when the summary must be dismissed or actioned before the form is reachable again — a genuine “stop and read” moment.
<dialog> + checkValidity() Trap Implementation
The most robust trap is a native <dialog> element opened with showModal(), which the browser already renders in the top layer and makes inert to the page behind it. We layer an explicit Tab guard on top so older engines and nested-widget edge cases still cycle correctly. The baseline stays novalidate plus a single manual checkValidity() pass.
// summary-trap.ts — native <dialog> error summary with an explicit Tab guard.
const form = document.querySelector<HTMLFormElement>('#signup')!;
const dialog = document.querySelector<HTMLDialogElement>('#error-summary')!;
const list = dialog.querySelector<HTMLUListElement>('.summary-list')!;
// Selector for anything the user can Tab to inside the dialog.
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';
let lastTrigger: HTMLElement | null = null;
form.addEventListener('submit', (event) => {
// novalidate on the <form> means the browser never blocks us; we drive it.
if (form.checkValidity()) return; // valid — let it submit
event.preventDefault();
const invalid = [...form.elements].filter(
(el): el is HTMLInputElement =>
el instanceof HTMLInputElement && !el.validity.valid,
);
// Rebuild the summary as links that jump straight to each broken field.
list.replaceChildren(
...invalid.map((field) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${field.id}`;
link.textContent = field.validationMessage; // native message text
link.addEventListener('click', (e) => {
e.preventDefault();
dialog.close(field.id); // returnValue carries the target id
});
li.append(link);
return li;
}),
);
lastTrigger = document.activeElement as HTMLElement;
dialog.showModal(); // top layer + inert background, for free
dialog.querySelector<HTMLElement>(FOCUSABLE)?.focus();
});
// Explicit Tab guard: keep focus cycling within the dialog.
dialog.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const nodes = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE)];
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
});
// On close, restore focus: to the chosen field, else to the trigger.
dialog.addEventListener('close', () => {
const targetId = dialog.returnValue;
const target =
(targetId && document.getElementById(targetId)) || lastTrigger;
target?.focus();
});
The dialog markup only needs an id, a heading, the empty list, and a close control:
<dialog id="error-summary" aria-labelledby="summary-heading">
<h2 id="summary-heading" tabindex="-1">Please fix these fields</h2>
<ul class="summary-list"></ul>
<button type="button" onclick="this.closest('dialog').close()">Close</button>
</dialog>
Trap Configuration: Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
FOCUSABLE |
string (CSS selector) |
links/buttons/inputs/[tabindex] |
Defines which nodes the Tab guard cycles through. Widen it for custom widgets. |
showModal() vs show() |
method call | showModal() |
showModal() renders in the top layer and makes the background inert; show() does neither and needs a manual trap. |
returnValue |
string |
'' |
Set via dialog.close(id) to tell the close handler which field to focus on dismissal. |
heading tabindex |
-1 |
-1 |
Lets the heading receive programmatic focus so screen readers announce the summary title. |
restore target |
HTMLElement |
lastTrigger |
The element focused after close; falls back to the submit trigger when no field was chosen. |
Escape handling |
native | enabled | <dialog> fires cancel then close on Escape; the same restore logic applies. |
Verification Steps
Confirm both the trap and the focus restoration, since either can silently regress.
import { test, expect } from '@playwright/test';
test('Tab is trapped inside the error summary', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Create account' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Cycle past the last focusable node; focus must stay inside the dialog.
for (let i = 0; i < 6; i++) await page.keyboard.press('Tab');
const trapped = await dialog.evaluate((d) => d.contains(document.activeElement));
expect(trapped).toBe(true);
});
Edge Cases & Failure Modes
Empty dialog swallows focus. If checkValidity() fails but your FOCUSABLE query returns nothing (for example a summary with only static text), showModal() moves focus to the dialog element itself and Tab has nowhere to go. Always include at least one focusable control — the Close button — and give the heading tabindex="-1" as a focus fallback.
Focus restored to a now-hidden field. With cross-field validation or progressive disclosure, the field that failed may be collapsed by the time the dialog closes. Guard the close handler by checking target.offsetParent !== null before focusing, and fall back to lastTrigger when the target is not visible.
Async check resolves after close. When asynchronous server checks populate the summary late, a user may dismiss the dialog before results land, and a queued focus() yanks focus back unexpectedly. Abort in-flight checks on close and only reopen the dialog if new errors arrive while it is not already open.
Falling Back to the inert Attribute When showModal() Is Unavailable
showModal() earns its keep because it makes the rest of the document inert for free: background nodes drop out of the tab order, stop firing pointer events, and are hidden from the accessibility tree. If you cannot use a native <dialog> — because you are rendering the summary into an existing container, or supporting an engine without top-layer support — you have to reproduce that inertness yourself. The inert attribute, now baseline across current browsers, is the honest way to do it: set it on every sibling of the dialog rather than trying to police focus with Tab handlers alone.
The distinction matters because a keydown trap only catches keyboard tabbing. It does nothing about a screen-reader user swiping through the virtual buffer, a mouse click landing on a background field, or a focus() call fired by some other script. Marking the background inert closes all three holes at once, so treat the Tab guard as a refinement on top of inertness, never a replacement for it.
// inert-fallback.ts — trap without <dialog> by making siblings inert.
function trapWithInert(panel: HTMLElement): () => void {
const siblings = [...(panel.parentElement?.children ?? [])].filter(
(node): node is HTMLElement => node instanceof HTMLElement && node !== panel,
);
// Remember prior state so we can restore mixed inert/aria-hidden pages.
const restore = siblings.map((node) => ({
node,
wasInert: node.hasAttribute('inert'),
priorHidden: node.getAttribute('aria-hidden'),
}));
for (const node of siblings) {
node.setAttribute('inert', '');
node.setAttribute('aria-hidden', 'true'); // older AT that ignores inert
}
// Return a teardown that un-inerts exactly what we changed.
return () => {
for (const { node, wasInert, priorHidden } of restore) {
if (!wasInert) node.removeAttribute('inert');
if (priorHidden === null) node.removeAttribute('aria-hidden');
else node.setAttribute('aria-hidden', priorHidden);
}
};
}
Call trapWithInert(panel) right after you reveal the summary and keep the returned teardown function; invoke it in the same close path that restores focus. Recording the prior inert and aria-hidden state — rather than blindly stripping the attributes — keeps you from clobbering nodes that were already hidden for unrelated reasons, such as an off-screen navigation drawer.
Reclaiming the :focus-visible Ring After Programmatic Focus
A subtle regression appears once you start calling field.focus() yourself: whether the browser paints a focus ring depends on the heuristic behind :focus-visible, and a programmatic focus that follows a mouse interaction often paints nothing at all. A keyboard user who dismissed the dialog with Enter on a summary link generally does get the ring, but do not rely on that — a visible target after restore is the whole point of the exercise. Pass focusVisible: true to focus() where it is supported, and provide a scripted fallback for engines that ignore the hint.
// Force a visible ring on restore, regardless of the prior input modality.
function focusVisibly(el: HTMLElement): void {
// Newer engines honour the focusVisible option directly.
el.focus({ focusVisible: true } as FocusOptions);
// Fallback: if no ring was painted, flag it with a class your CSS targets
// (.was-restored:focus { outline: 2px solid Highlight }).
if (!el.matches(':focus-visible')) {
el.classList.add('was-restored');
el.addEventListener('blur', () => el.classList.remove('was-restored'), {
once: true,
});
}
}
Wire focusVisibly(target) into the close handler in place of the bare target?.focus() call. The result is that the field needing attention always shows a ring on return, which is exactly the affordance a sighted keyboard user needs to resume fixing the form without hunting for the caret.
Frequently Asked Questions
Do I still need a manual Tab guard if I use the native <dialog> element?
For most modern browsers, showModal() already traps focus and makes the background inert, so a manual guard is belt-and-suspenders. Keep the explicit keydown handler if you support older engines or embed custom focusable widgets whose tab order the browser handles inconsistently. It is a few lines and costs nothing when the native trap already works.
Should the summary links show native messages or my own copy?
The example uses field.validationMessage, which reflects any custom validity messages you have set. If you validate with something like Zod schema validation, map each issue back to its field and write that text into the link instead. Either way, keep the message and the field's inline error identical.
How do I announce the summary to screen readers when it opens?
Because showModal() moves focus into the dialog and it is labelled via aria-labelledby, most screen readers announce the heading automatically. For extra certainty, move focus to the tabindex="-1" heading rather than the first link. Broader guidance on wording lives in accessible error messaging.
Related Guides
- Managing Focus After Validation Failure — the non-modal counterpart when you do not need to block the form.
- When to Use a Toast vs an Inline Error — decide whether a modal summary is even the right delivery channel.
- Inline Error Messaging Strategies — keep the summary text and per-field messages consistent.
- Focus Management & Keyboard Navigation — the wider set of keyboard-focus recipes this page belongs to.
- Designing Accessible Error Toast Notifications — the accessible pattern for the lighter-weight alternative.