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.

Focus routing on submit failure: error summary and first-invalid paths A submit-failure node branches into two routes. One goes directly to the first invalid field. The other goes to an error summary listing two errors as anchor links, which route focus down to the email and password fields. A vertical tab-order arrow runs through the fields. submit fails checkValidity() === false choose a route error summary (role="alert") 2 errors found: → Email is required → Password too short Form fields (tab order ↓) tabindex order preserved #email · aria-invalid="true" first invalid field #password · aria-invalid="true" second invalid field #country · valid direct: first invalid anchor links → fields
On submit failure, route focus either straight to the first invalid field or to an error summary whose anchor links carry focus into each field — never leave it stranded on the submit button.

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;
}

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
}

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-invalid and aria-describedby so the screen reader announces both the location and the reason on arrival.
  • Error summary as role="alert" — focusing the summary while it carries role="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.

← Back to UX Patterns & Error State Design

Explore This Section