Managing Focus After Validation Failure
This recipe gives you the exact code to move keyboard and screen-reader focus to the right place the instant a form submission fails validation — the first invalid field or an error summary — without dead ends, broken tab order, or focus calls that fire before the DOM is ready. Improper handling here violates WCAG 2.2 Success Criterion 3.3.1 (Error Identification) and strands users on a submit button below a wall of unseen errors.
It is the focused, copy-ready companion to Focus Management & Keyboard Navigation, which covers the broader architecture; here we solve one moment: submit fails, where does focus go.
When to Use This Recipe
Use this pattern whenever:
- A
<form novalidate>submit handler runscheckValidity()and finds at least one invalid field. - You manage validation in JavaScript and have suppressed the browser’s native focus-and-bubble behavior.
- Your form lives in an SPA where DOM updates are batched, so focus calls can outrun the render.
If validation passes on submit, this recipe doesn’t apply — focus stays on the natural submit flow. It activates only on failure.
Minimal Working Implementation
The core: on failed submit, find the first field marked aria-invalid="true", defer focus to the next paint so the field’s error state has rendered, then focus and conditionally scroll it.
// focus-on-failure.ts — route focus to the first invalid field on submit
export function handleSubmit(form: HTMLFormElement, event: SubmitEvent): void {
if (form.checkValidity()) return; // valid — let submission proceed
event.preventDefault();
// Mark fields and render messages first (your own pass), then route focus.
markInvalidFields(form);
// Defer one paint so aria-invalid + error containers exist before focusing.
requestAnimationFrame(() => {
const firstInvalid = form.querySelector<HTMLElement>('[aria-invalid="true"]');
if (!firstInvalid) return;
firstInvalid.focus({ preventScroll: true });
const rect = firstInvalid.getBoundingClientRect();
const inView = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!inView) {
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
firstInvalid.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "center",
});
}
});
}
// Stub: replace with your validity-to-aria-invalid pass.
function markInvalidFields(form: HTMLFormElement): void {
form.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach(
(f) => f.setAttribute("aria-invalid", String(!f.checkValidity())),
);
}
Pair the focus shift with aria-invalid="true" and an aria-describedby link to the message, so the screen reader announces both the location and the reason on arrival — the same wiring detailed in Inline Error Messaging Strategies.
Safe Focus with a Summary Fallback
When the first invalid field may be detached, hidden, or inside a collapsed step, fall back to a persistent error summary instead of calling .focus() on nothing.
// safe-focus.ts — focus the field, or fall back to the error summary
export function safeFocusShift(targetId: string, fallbackId: string): void {
const target = document.getElementById(targetId);
// offsetParent === null means display:none or detached — not focusable.
if (target && target.offsetParent !== null) {
target.focus({ preventScroll: false });
return;
}
const fallback = document.getElementById(fallbackId);
if (fallback) {
fallback.setAttribute("tabindex", "-1"); // make it programmatically focusable
fallback.focus();
}
}
Parameter Reference
| Parameter / option | Type | Default | Effect |
|---|---|---|---|
preventScroll |
boolean |
false |
When true, focus without scrolling so you control scroll separately (avoids a double jump). |
block (scrollIntoView) |
"start" | "center" | "nearest" |
"center" |
Where the field lands in the viewport. "center" clears most sticky headers. |
behavior (scrollIntoView) |
"auto" | "smooth" |
branch on motion pref | Use "auto" when prefers-reduced-motion: reduce matches. |
tabindex="-1" |
attribute | — | Required on a non-interactive summary container so it can receive programmatic focus. |
Verification Steps
- After a failed submit, run
document.activeElementin the console — it must be the first invalid field (or the summary), not the submit button. - In Chrome DevTools, open the Rendering panel and enable Layout Shift Regions; a correct deferred focus produces no shift around the focus call.
- Throttle the network to Slow 3G and trigger async validation to confirm focus still lands once the late
aria-invalidattribute appears.
// focus-failure.spec.ts — Playwright: focus lands on first invalid field
import { test, expect } from "@playwright/test";
test("focus routes to first invalid field on failed submit", async ({ page }) => {
await page.goto("/checkout");
await page.click("#submit"); // submit empty form
const email = page.locator("#email");
await expect(email).toBeFocused();
await expect(email).toHaveAttribute("aria-invalid", "true");
});
When the focus shift silently fails, work through these checks in order:
- Confirm
document.activeElementis not still the submit button — if it is, the call ran before the field was focusable. - Verify the target has no
pointer-events: none,visibility: hidden, ordisplay: none; any of these blocks native focus. - Check that no
tabindexoverride has created a negative-index trap that the[aria-invalid="true"]selector matches but the browser refuses to focus. - Log
performance.now()immediately before and after the.focus()call to catch a forced synchronous layout that delays the paint your deferral depends on.
Edge Cases & Failure Modes
The field mounts after validation. Async server validation can return field IDs before those fields render, or a multi-step flow may not have mounted the step yet. Use a short-lived MutationObserver to focus the target the moment it appears, with a timeout so you don’t observe forever.
// focus-when-ready.ts — focus a field that may not exist yet
export function focusWhenReady(id: string, timeoutMs = 1000): void {
const existing = document.getElementById(id);
if (existing) { existing.focus(); return; }
const observer = new MutationObserver(() => {
const el = document.getElementById(id);
if (el) { el.focus(); observer.disconnect(); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), timeoutMs);
}
Focus inside a <dialog> that closes on failure. If the dialog programmatically closes, focus is lost to document.body. Keep the dialog open on validation failure and route focus within it; only close on success.
Synchronous .focus() swallowed by reconciliation. Calling .focus() directly inside a React/Vue state update runs before the commit, so it no-ops. Always defer with requestAnimationFrame (sometimes two nested frames) — the broader timing model is in Focus Management & Keyboard Navigation.
Multi-step forms split errors across unmounted steps. When a wizard validates the whole form on final submit but invalid fields live on an earlier, currently-unmounted step, you can’t focus a node that isn’t in the DOM. Navigate to the step that owns the first error before routing focus, then defer the focus call until that step has mounted.
// multistep-focus.ts — switch to the failing step, then focus its field
export function focusFirstErrorAcrossSteps(
errors: { stepIndex: number; fieldId: string }[],
goToStep: (i: number) => void,
): void {
if (errors.length === 0) return;
const first = errors[0];
goToStep(first.stepIndex); // mount the step that owns the error
focusWhenReady(first.fieldId); // focus once that field renders
}
This is why an error summary that aggregates problems across every step is valuable: it gives the user a single place to see all failures and an anchor link that both switches steps and routes focus, the structure built in Focus Management & Keyboard Navigation.
Frequently Asked Questions
Why does my focus call fail right after submit in React or Vue?
The .focus() call is running before the framework has committed the DOM, so the
aria-invalid attribute and error container don't exist yet and focus stays on the submit
button. Wrap the call in requestAnimationFrame (occasionally two nested frames) so it runs
after the next paint, once the invalid state has rendered.
Should I focus the field or its error message?
Focus the field, not the message. The user needs to be in the input ready to correct it. Link the
field to its message with aria-describedby so the screen reader reads the error text as
part of announcing the focused field — that delivers the reason without moving focus to non-interactive
text.
What if the first invalid field is hidden or not yet in the DOM?
Guard before focusing: check offsetParent !== null to confirm the element is rendered
and visible, and fall back to a persistent error summary (with tabindex="-1") when it
isn't. For fields that mount slightly later — async validation or multi-step flows — a short-lived
MutationObserver focuses the target the moment it appears.
Related Guides
- Focus Management & Keyboard Navigation — the full focus architecture this recipe draws from.
- Inline Error Messaging Strategies — the
aria-describedbywiring that makes the focused field announce its error. - Best Practices for Inline Validation Timing — the per-field timing that precedes the submit boundary.
- Form Submission Lifecycle — the submit event where this routing is triggered.