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 runs checkValidity() 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.

Post-failure focus decision flow Submit runs checkValidity. If valid, it proceeds. If invalid, it defers one paint, then focuses the first invalid field when it is visible, or an error summary when it is not. submit checkValidity() false defer 1 paint requestAnimationFrame visible hidden focus first invalid field offsetParent !== null focus error summary tabindex="-1" fallback
Failure defers one paint so the invalid state has rendered, then focuses the first visible invalid field — or the error summary when that field isn't focusable.

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.activeElement in 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-invalid attribute 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.activeElement is 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, or display: none; any of these blocks native focus.
  • Check that no tabindex override 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.

← Back to Focus Management & Keyboard Navigation