Skip to main content

Testing & Scanners

Accessible Form Validation: Errors, Live Regions, and Recovery

Last updated

This guide picks up where form labels leaves off. Labels tell users what a field is for; validation tells them what went wrong, where, and how to recover. The accessible validation pattern is four moving parts working together. First, every field that fails validation gets aria-invalid="true" and an aria-describedby pointing at a visible, in-DOM error message — not a tooltip, not a title attribute. Second, a summary box renders at the top of the form on submit when any errors exist; it lists each error with an anchor link to the field that produced it. Third, focus moves programmatically to that summary (or to the first invalid field) the moment submit fails — keyboard and screen-reader users should never have to hunt for what broke. Fourth, an aria-live region announces the result of asynchronous submission (success, server error, network failure) without requiring a page reload.

The dominant pattern in the industry — used by GOV.UK, GitHub, Stripe, and most government services that have been audited under WCAG — combines the inline error and the summary. The summary is the gold-standard piece: it gives a single place to discover everything that went wrong, and the in-summary anchor links jump focus directly to the broken field. If you only do one thing on a long form, build the summary.

Why browser-default validation is broken

If you set required and pattern on an input and let the browser handle errors, what users see is a floating tooltip near the field. Adrian Roselli's long-running avoid default field validation analysis catalogues every reason that tooltip is not good enough: it disappears after a few seconds, it is not styleable in any meaningful way across browsers, the message text comes from the browser locale rather than your copy, it does not persist for users who pause to think, and screen-reader announcement of the tooltip is inconsistent across the assistive-technology matrix. Worse, the tooltip is anchored to a single field — there is no concept of “here are all five things wrong with this form”.

The right move is the one every serious design system has settled on: add novalidate to the <form> element so the browser stays out of the way, and own the validation, messaging, and focus management yourself. Keep the underlying attributes (required, type="email", inputmode, autocomplete) — they still help mobile keyboards, password managers, and autofill — just suppress the default UI.

Inline error messages — the four moving parts

For every field that can produce an error, you need four things in place. The input itself carries an aria-describedby pointing at the error element's ID. The same input toggles aria-invalid="true" once an error exists, and back to "false" (or removes the attribute) once the user corrects it. The error message is a real, persistent element in the DOM — typically a <p> immediately after the input — not a popover or a title attribute. And the visual cue is more than just color: an icon plus text, or a prefix like “Error:”, so that color-blind users and users who turn off CSS still see the error. The W3C error notifications tutorial and MDN's aria-describedby reference both spell out the association mechanics.

inline-error.htmlhtml
<form novalidate>
  <div class="field">
    <label for="email">Email address</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      required
      aria-invalid="true"
      aria-describedby="email-error"
    />
    <p id="email-error" class="error">
      <svg aria-hidden="true" focusable="false" width="16" height="16">
        <!-- error icon -->
      </svg>
      <span>Error: Enter a valid email address, e.g. name@example.com</span>
    </p>
  </div>
</form>

One important behavioral rule: do not fire the error on every keystroke. Validating-on-input announces a stream of half-finished mistakes to screen-reader users — “Error: invalid email” on every typed character — and feels punitive to everyone. The accepted pattern is to validate on submit first, then switch the field to validate-on-blur once it has already produced an error, so the user sees their correction confirmed without being scolded mid-type. The MDN aria-invalid reference notes that the attribute is meaningful only after a user has attempted to submit or has otherwise indicated they are done with the field.

The error summary at the top of the form

On submit, if any field fails validation, render a summary box at the very top of the form. It should have a heading (so screen-reader users can land on it via heading navigation), a count of errors, and a list of anchor links — one per error — that jump focus to the offending field. Move keyboard focus to the summary as soon as it renders. This is the GOV.UK Design System pattern, and it is the single highest-leverage thing you can do for forms with more than two fields.

ErrorSummary.tsxtsx
type FieldError = { id: string; message: string };

export function ErrorSummary({ errors }: { errors: FieldError[] }) {
  const ref = useRef<HTMLDivElement>(null);

  // Move focus to the summary every time it appears.
  useEffect(() => {
    if (errors.length > 0) ref.current?.focus();
  }, [errors]);

  if (errors.length === 0) return null;

  return (
    <div
      ref={ref}
      tabIndex={-1}
      role="alert"
      aria-labelledby="error-summary-title"
      className="error-summary"
    >
      <h2 id="error-summary-title">
        There {errors.length === 1 ? 'is 1 problem' : `are ${errors.length} problems`} with your submission
      </h2>
      <ul>
        {errors.map((e) => (
          <li key={e.id}>
            <a href={`#${e.id}`}>{e.message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

A few details matter here. The wrapper uses tabIndex={-1} so it can receive programmatic focus but is not part of the natural tab order. The role="alert" ensures the heading and list are announced when the summary appears, even if you forget to move focus. Each anchor target is the id of the actual input — not the label, not the wrapper — so clicking the link or activating it with Enter lands keyboard focus directly on the broken field, ready for correction.

aria-live for async submit feedback

When the form is submitted to a server and the result arrives asynchronously (the usual case for Server Actions, fetch-based submits, and SPA forms), screen-reader users need an audible confirmation of what happened. Use a polite live region for success messages and an assertive one for failures, and — this is the pitfall — keep both regions in the DOM at initial render. A region that only appears after the announcement event will not announce reliably, because most assistive tech needs the region present at parse time to monitor changes.

LiveAnnouncer.tsxtsx
export function LiveAnnouncer({
  status,
  errorMessage,
}: {
  status: 'idle' | 'submitting' | 'success' | 'error';
  errorMessage?: string;
}) {
  // Both regions are always rendered. Their text content changes;
  // the regions themselves do not appear and disappear.
  return (
    <>
      <div role="status" aria-live="polite" className="sr-only">
        {status === 'submitting' && 'Submitting your form…'}
        {status === 'success' && 'Form submitted successfully.'}
      </div>
      <div role="alert" aria-live="assertive" className="sr-only">
        {status === 'error' && errorMessage}
      </div>
    </>
  );
}

The Smashing Magazine guide to accessible form validation has a fuller walk-through of live-region choreography, including the edge case of consecutive identical messages (which some screen readers de-duplicate; the fix is to briefly clear the region before re-setting it).

React Hook Form + Zod — an accessible pattern

The two patterns above plug into React Hook Form cleanly. RHF provides formState.errors and setFocus; Zod gives you a single schema that drives both client and server validation. The accessible wiring is: render the summary from formState.errors, set aria-invalid and aria-describedby on each registered input, and call setFocus on the first invalid field from onInvalid.

SignupForm.tsxtsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Enter a valid email address, e.g. name@example.com'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});
type FormValues = z.infer<typeof schema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    setFocus,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ resolver: zodResolver(schema), mode: 'onSubmit' });

  const errorList = Object.entries(errors).map(([name, err]) => ({
    id: name,
    message: err?.message ?? 'Invalid value',
  }));

  return (
    <form
      noValidate
      onSubmit={handleSubmit(
        async (values) => { /* submit */ },
        // onInvalid: move focus to the first broken field.
        (formErrors) => {
          const first = Object.keys(formErrors)[0] as keyof FormValues;
          if (first) setFocus(first);
        },
      )}
    >
      <ErrorSummary errors={errorList} />

      <label htmlFor="email">Email address</label>
      <input
        id="email"
        type="email"
        autoComplete="email"
        aria-invalid={errors.email ? 'true' : 'false'}
        aria-describedby={errors.email ? 'email-error' : undefined}
        {...register('email')}
      />
      {errors.email && (
        <p id="email-error" className="error">
          Error: {errors.email.message}
        </p>
      )}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        autoComplete="new-password"
        aria-invalid={errors.password ? 'true' : 'false'}
        aria-describedby={errors.password ? 'password-error' : undefined}
        {...register('password')}
      />
      {errors.password && (
        <p id="password-error" className="error">
          Error: {errors.password.message}
        </p>
      )}

      <button type="submit" disabled={isSubmitting}>
        Create account
      </button>
    </form>
  );
}

Note the choice to move focus to the first invalid field, not to the summary. Either is defensible; the GOV.UK guidance prefers the summary because it gives a complete inventory of what is wrong. If your form is short (one to three fields), first-field-focus feels faster; for anything longer, summary-focus wins. Pick one and apply it consistently across the product. For broader React-specific accessibility patterns, see our accessible React guide.

Common error patterns to avoid

  • Color-only error indication. A red border with no text fails WCAG SC 1.4.1 (Use of Color). Always pair the color cue with an icon and a text message.
  • Errors that vanish on first keystroke. Wiping the error the moment the user starts editing means they lose the context they need to remember what to fix. Keep the message until the field re-validates successfully.
  • Errors that announce on every keystroke. Validate-on-input flooded into screen-reader output is hostile. Validate on submit; downgrade to validate-on-blur once a field has already errored.
  • Errors not linked to the field via aria-describedby. A visible red message that the screen reader never associates with the input might as well not exist for assistive-tech users.
  • Submit button disabled until the form is valid. A grey button gives no information about what is wrong. Let users click submit, then show them every error at once via the summary. This is also the WCAG-aligned pattern in the W3C forms notifications tutorial.

Server Action / async validation

With Next.js App Router Server Actions (or any other server-rendered submit), the validation result arrives after a network round trip. The accessible pattern is identical to the client-only case — summary at the top, inline errors on each field, focus moved on submit — but the state lives in useActionState (or your framework's equivalent) rather than RHF. The same Zod schema can validate on both sides: parse on the server to enforce security, return the parsed errors, and re-hydrate the summary from them on the next render. The aria-live region announces “submitting” on pending and the final result on completion. The full mechanics, including focus management across Server Action transitions, are in our accessible Next.js guide.

Frequently asked questions

Should I disable browser default validation?
Yes — add novalidate to the form element and own the error UI. Default browser bubbles disappear after a few seconds, are not styleable, use the browser locale instead of your copy, and have inconsistent screen-reader announcement across the assistive-tech matrix. Adrian Roselli has the long-form analysis. Keep the underlying HTML attributes (required, type="email", inputmode, autocomplete) because they still help mobile keyboards, password managers, and autofill — just suppress the default UI and replace it with your own.
When should errors first appear?
On submit. Validate-on-input announces a stream of half-finished mistakes to screen-reader users and feels punitive to everyone else. The accepted pattern is validate-on-submit first, then switch a specific field to validate-on-blur once it has already produced an error. That way the user sees their correction confirmed when they tab away from a previously broken field, without being scolded mid-type on every other field.
Is aria-live="assertive" the right choice for errors?
For the final submission outcome ("There was a server error saving your changes") yes — use role="alert" with aria-live="assertive". For routine inline-field errors after a submit attempt, no — the error summary at the top of the form with role="alert" handles the announcement once, in one place. Sprinkling assertive on every individual field-error element causes overlapping announcements that users describe as chaotic. Use polite (role="status") for progress and success messages.
Does aria-invalid require aria-describedby?
They are independent attributes that almost always travel together. aria-invalid tells assistive tech that the field is in an error state; aria-describedby tells it where to find the explanation. A field with aria-invalid="true" and no aria-describedby leaves the user knowing something is wrong but not what. A field with aria-describedby pointing at an error but no aria-invalid is also wrong — the input is not flagged as in an error state. Set both, toggle both, and remove both together when the field is corrected.
Will my scanner catch missing aria-describedby?
Partially. SweepHound flags fields that are visually marked as errored (red border, error icon, error-class CSS) but have no aria-describedby pointing at any text — those are unambiguous violations. What no scanner can detect is whether the described element actually contains the right message, whether focus moves to the summary on submit, or whether validate-on-input is announcing every keystroke. Those are manual-review items. See our manual accessibility checklist for the full keyboard-and-screen-reader pass.

How SweepHound checks form validation

The dual-engine scan flags the deterministic pieces. Missing labels are caught by the same rules covered in the form labels guide. Fields visibly marked as errored (a red-border CSS class, an error icon, an aria-invalid attribute) but with no resolvable aria-describedby target are flagged as a likely error-association failure. Submit buttons that are not real <button type="submit"> elements get caught by the broader form-semantics rule, and inputs missing an autocomplete attribute on personal-information fields are flagged under WCAG 1.3.5.

What the scan cannot determine is the runtime behavior — whether focus actually moves to the summary on submit, whether the live region announces the result, whether validate-on-input is spamming announcements, or whether the error copy is helpful rather than just present. Those questions need a human at a keyboard and a screen reader. The manual accessibility checklist and the keyboard-only test from our keyboard navigation guide cover the manual pass. For the broader page-structure work that forms sit inside, see our ARIA landmarks guide.

To see what a dual-engine scan flags on your forms, start a free scan. Free includes one site and an unauthenticated crawl; paid tiers add scheduled rescans, authenticated scanning of logged-in flows (which is where most real-world validation lives), and the accessibility statement generator — see pricing for the breakdown. The fastest way to know your validation is accessible is to scan a logged-in version of the form and pair it with the manual checklist.

Sources

  1. W3C, Forms TutorialPrimary W3C reference for accessible form construction.
  2. W3C, Forms Tutorial — User NotificationsNormative pattern for error identification, suggestion, and summary.
  3. MDN, aria-describedbyAttribute reference for associating inputs with help and error text.
  4. MDN, aria-invalidAttribute reference for flagging a field as in an error state.
  5. Adrian Roselli, Avoid Default Browser Form Field ValidationLong-running practitioner analysis of why browser-default validation is broken.
  6. Smashing Magazine, Guide To Accessible Form ValidationPractitioner reference covering inline errors, summary, and live regions.