Focus Management & Keyboard Navigation
Deterministic focus control is the architectural foundation of an accessible, JavaScript-driven form. When a validation cycle mutates the DOM, injects an error summary, or conditionally reveals a section, the browser’s default focus behavior rarely matches what a keyboard or screen reader user expects. Predictable focus routing preserves cognitive context, satisfies WCAG 2.2 Success Criteria 2.1.1 (Keyboard) and 2.4.3 (Focus Order), and keeps users from losing their place mid-correction.
This guide covers where focus should land when a submit fails, how an error summary’s anchor links route focus to individual fields, how to preserve a sane tab order, and how to time .focus() calls so they don’t fire before the target is painted. It is a core part of UX Patterns & Error State Design and the natural endpoint of Inline Error Messaging Strategies: once a message exists, focus is how the user reaches it.
The Problem: Where Does Focus Go on Submit Failure?
A keyboard user presses Enter on the submit button. Validation fails. At that instant the browser does nothing useful — focus stays on the submit button, which is now at the bottom of a form full of errors the user can’t see. The job of focus management is to route focus to the right place: either directly to the first invalid field, or to an error summary that lists every problem with anchor links into the form.
The diagram shows both paths. On failure, the controller either jumps focus straight to the first invalid field, or sends it to an error summary whose links — when activated — route focus down to the matching field. The tab order through the form is preserved either way.
Prerequisites
| Requirement | Why it matters |
|---|---|
<form novalidate> + checkValidity() |
You decide what happens on failure instead of the browser showing its native bubble and focusing the field for you. |
aria-invalid="true" on failed fields |
The selector your focus router queries to find invalid fields. |
A stable id on every input |
Anchor links (href="#email") and programmatic getElementById both need it. |
An error summary container with role="alert" |
Announces the error count and hosts the anchor links. |
:focus-visible styling |
Renders a clear focus ring for keyboard users without polluting mouse clicks. |
API Reference: The Focus Primitives
| API | Role in focus routing |
|---|---|
element.focus({ preventScroll }) |
Moves focus; preventScroll: true lets you control scrolling separately to avoid a jarring jump. |
element.scrollIntoView({ behavior, block }) |
Brings the target into view; respect prefers-reduced-motion before using smooth. |
document.activeElement |
The current focus owner — assert against it to verify a routing call landed. |
requestAnimationFrame |
Defers .focus() to the next paint so the target is in the accessibility tree first. |
element.getBoundingClientRect() |
Detects whether the target is already in the viewport before scrolling. |
tabindex="-1" |
Makes a non-interactive container (an error summary) programmatically focusable without adding it to tab order. |
Step-by-Step Implementation
Step 1 — Route focus to the first invalid field
The baseline behavior: query invalid fields in DOM order, focus the first, and scroll it into view only if it’s off-screen.
// route-first-invalid.ts — WCAG 2.4.3 compliant first-error routing
export function routeFocusToFirstInvalid(form: HTMLFormElement): boolean {
const invalid = form.querySelector<HTMLElement>('[aria-invalid="true"]');
if (!invalid) return false;
invalid.focus({ preventScroll: true });
const rect = invalid.getBoundingClientRect();
const inView = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!inView) {
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
invalid.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "center",
});
}
return true;
}
Step 2 — Build a focusable error summary with anchor links
For longer forms, send focus to a summary first. The container takes tabindex="-1" so you can focus it programmatically, and each error is an anchor into the offending field.
// error-summary.ts — render and focus an accessible error summary
interface FieldError { id: string; label: string; message: string; }
export function showErrorSummary(errors: FieldError[]): void {
const summary = document.getElementById("form-error-summary");
if (!summary || errors.length === 0) return;
const links = errors
.map((e) => `<li><a href="#${e.id}">${e.label}: ${e.message}</a></li>`)
.join("");
summary.innerHTML = `<h2>${errors.length} error${
errors.length > 1 ? "s" : ""
} found</h2><ul>${links}</ul>`;
summary.hidden = false;
summary.setAttribute("tabindex", "-1"); // focusable, not in tab order
summary.focus(); // role="alert" + focus = announced and reachable
}
Step 3 — Carry focus from an anchor link into the field
A bare href="#email" scrolls the field into view but does not always focus it. Intercept the click (or the hashchange) and call .focus() so the user lands in the field ready to type.
// summary-links.ts — make anchor links actually focus their target
document
.getElementById("form-error-summary")
?.addEventListener("click", (e) => {
const link = (e.target as HTMLElement).closest("a");
if (!link) return;
const id = link.getAttribute("href")?.slice(1);
const field = id && document.getElementById(id);
if (field) {
e.preventDefault();
field.focus(); // default scroll is fine here — user asked to jump
}
});
Step 4 — Defer focus until the DOM has painted
In SPAs that batch updates, a synchronous .focus() after a state change can run before the field has its aria-invalid attribute or before the summary exists. Defer to the next frame.
// deferred-focus.ts — wait one paint so the target is in the a11y tree
export function focusAfterPaint(run: () => void): void {
requestAnimationFrame(() => requestAnimationFrame(run));
}
The exact recipe for tying this deferral to your framework’s reconciliation cycle, plus fallbacks for fields that mount late, is covered in Managing Focus After Validation Failure.
State Management & Edge Cases
Debounce overlapping focus shifts
Rapid validation across several fields can queue competing .focus() calls that thrash. Serialize them through a single queue processed one frame at a time.
// focus-queue.ts — one focus shift per frame, no thrashing
const queue: HTMLElement[] = [];
let running = false;
export function enqueueFocus(el: HTMLElement): void {
queue.push(el);
if (running) return;
running = true;
const step = () => {
const next = queue.shift();
if (!next) { running = false; return; }
next.focus({ preventScroll: true });
requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
Conditionally revealed sections
When Progressive Disclosure Techniques reveal a new section, the browser can’t focus an element that hasn’t painted. Wait for requestAnimationFrame — and a transitionend if the section animates in — before focusing its first field.
Mobile virtual keyboards
A software keyboard covers the lower viewport, so a focused field can sit behind it. Read window.visualViewport height and offset your scrollIntoView block so the field clears the keyboard.
// keyboard-aware-scroll.ts — keep the focused field above the soft keyboard
export function scrollClearOfKeyboard(field: HTMLElement): void {
const vv = window.visualViewport;
if (!vv) { field.scrollIntoView({ block: "center" }); return; }
const rect = field.getBoundingClientRect();
// visualViewport.height shrinks when the keyboard opens.
if (rect.bottom > vv.height) {
window.scrollBy({ top: rect.bottom - vv.height + 24, behavior: "smooth" });
}
}
Restoring focus across navigation
When a user navigates away and returns — a browser back/forward, a wizard step change — restore focus to where they were rather than the top of the form. Serialize the active element’s id before navigating and refocus it on return.
// focus-restore.ts — remember and restore focus across navigation
const KEY = "form:focus";
export function rememberFocus(): void {
const el = document.activeElement as HTMLElement | null;
if (el?.id) sessionStorage.setItem(KEY, el.id);
}
export function restoreFocus(): void {
const id = sessionStorage.getItem(KEY);
const el = id && document.getElementById(id);
if (el) el.focus({ preventScroll: false });
}
Framework Integration
The routing functions operate on native DOM APIs, so they drop into any framework — but the timing of the call differs. In a component framework, run the focus shift after the render that sets aria-invalid, inside an effect rather than the event handler that triggered submission.
// useFocusFirstError.ts — focus after the invalid state has committed
function useFocusFirstError(submitted: boolean, errors: Record<string, string>) {
useEffect(() => {
if (!submitted || Object.keys(errors).length === 0) return;
// Effect runs after the DOM has aria-invalid applied — safe to focus.
const first = document.querySelector<HTMLElement>('[aria-invalid="true"]');
first?.focus({ preventScroll: true });
}, [submitted, errors]);
}
Wrapping the focus logic in a lifecycle hook (useEffect, onMounted, Angular’s ngAfterViewChecked) guarantees the invalid attributes exist before the query runs — the framework analog of the requestAnimationFrame deferral in Step 4. The deeper timing fallbacks for fields that mount late are in Managing Focus After Validation Failure.
Accessibility Compliance
- SC 2.1.1 Keyboard — every routing path must be reachable and operable by keyboard alone; never depend on a pointer to reach an invalid field.
- SC 2.4.3 Focus Order — focus must move in an order that preserves meaning. Routing to the first invalid field or summary is allowed; scrambling tab order is not.
- SC 3.3.1 Error Identification — pair the focus shift with the field’s
aria-invalidandaria-describedbyso the screen reader announces both the location and the reason on arrival. - Error summary as
role="alert"— focusing the summary while it carriesrole="alert"both announces the error count and lands the user where the anchor links are.
Common Gotchas
Gotcha 1 — Manipulating tabindex to skip valid fields. Setting positive tabindex or -1 on valid fields to “jump” the user breaks the natural order.
// Before: hijacks tab order
validFields.forEach((f) => f.setAttribute("tabindex", "-1"));
// After: leave tab order alone; route focus once, let the user tab normally.
routeFocusToFirstInvalid(form);
Gotcha 2 — Synchronous focus after a DOM swap. Focusing before paint silently fails.
// Before: target may not be in the a11y tree yet
container.innerHTML = sectionHTML;
container.querySelector("input")?.focus();
// After
container.innerHTML = sectionHTML;
focusAfterPaint(() => container.querySelector("input")?.focus());
Gotcha 3 — scrollIntoView({ behavior: "smooth" }) ignoring motion preferences. Forced smooth scrolling can disorient users who requested reduced motion — branch on prefers-reduced-motion as Step 1 does.
Gotcha 4 — A non-focusable error summary. A <div> without tabindex="-1" can’t receive programmatic focus, so the announcement lands nowhere. Always add tabindex="-1" before focusing it.
Browser Compatibility
| Feature | Chrome / Edge | Firefox | Safari | Mobile Safari |
|---|---|---|---|---|
focus({ preventScroll }) |
Yes | Yes | Yes | Yes |
:focus-visible |
Yes | Yes | Yes (15.4+) | Yes (15.4+) |
scrollIntoView({ behavior: "smooth" }) |
Yes | Yes | Yes | Partial |
visualViewport API |
Yes | Yes | Yes | Yes |
tabindex="-1" programmatic focus |
Yes | Yes | Yes | Yes |
:focus-visible is now supported across all current engines, so the legacy polyfill is no longer needed; rely on the native selector to render keyboard focus rings without affecting mouse interactions.
Verifying Focus Routing
jsdom has no real focus manager, so unit-test assertions on document.activeElement are unreliable for routing — verify focus in a real browser via Playwright, asserting against toBeFocused().
// focus-routing.spec.ts — focus lands on the first invalid field, not submit
import { test, expect } from "@playwright/test";
test("failed submit moves focus off the submit button", async ({ page }) => {
await page.goto("/checkout");
await page.locator("#submit").focus();
await page.keyboard.press("Enter"); // submit empty form
const email = page.locator("#email");
await expect(email).toBeFocused();
await expect(page.locator("#submit")).not.toBeFocused();
});
test("error summary link carries focus into its field", async ({ page }) => {
await page.goto("/checkout");
await page.locator("#submit").click();
await page.locator('#form-error-summary a[href="#password"]').click();
await expect(page.locator("#password")).toBeFocused();
});
Beyond automation, keep a manual matrix: tab and shift-tab the full form to confirm no focus lands on invisible elements, and run NVDA, JAWS, and VoiceOver to confirm the error summary’s role="alert" count is announced when focus arrives.
Frequently Asked Questions
Should focus go to the first invalid field or to an error summary?
For short forms with one or two fields, focus the first invalid field directly — it's the fastest path to correction. For longer forms, focus an error summary that lists every problem as an anchor link; it gives the user scope before they start fixing. Both are WCAG-compliant; the summary scales better as field count grows.
Why does my .focus() call do nothing after a DOM update?
The element almost certainly wasn't painted yet. A synchronous .focus() immediately
after a framework state change or innerHTML swap runs before the node is in the
accessibility tree, so focus silently stays where it was. Defer the call with
requestAnimationFrame (often two nested frames) or wait for the relevant
transitionend.
Do anchor links in an error summary focus the field, or just scroll to it?
A bare href="#field-id" scrolls the field into view and, for natively focusable
elements, browsers will usually focus it — but the behavior is inconsistent across engines for
wrapped or custom controls. Intercept the click and call .focus() explicitly so the user
reliably lands inside the field ready to type.
Is it ever acceptable to change tab order to guide the user through errors?
No. Manipulating tabindex to skip valid fields breaks the predictable order keyboard
users depend on and risks violating SC 2.4.3. Route focus once to the first error or the summary, then
let the user tab through the form normally. Use visual and ARIA states — not tab order — to draw
attention to invalid fields.
Related Guides
- Managing Focus After Validation Failure — the focused recipe for timing and fallbacks when a submit fails.
- Inline Error Messaging Strategies — the messages that focus routing carries the user to.
- Best Practices for Inline Validation Timing — when validation fires before focus ever needs to move.
- Progressive Disclosure Techniques — focusing fields inside sections revealed on demand.
- Form Submission Lifecycle — the submit event where failure routing begins.
← Back to UX Patterns & Error State Design