ESC

Type to search the knowledge base.

Accessible Forms Errors

Label inputs, associate errors with fields, announce failures, and avoid placeholder-only forms that break accessibility.

beginner3 min read
  • accessibility
  • accessible-forms

Broken forms are the highest-traffic a11y failures: missing labels, errors only in red borders, and focus that never moves to the problem. Accessible forms are mostly correct HTML plus clear error UX.

Docs: WCAG Forms tutorials, MDN labels.

Labels are mandatory

<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" />

Placeholder is not a label — it disappears on input and has weaker SR support.

<!-- Bad -->
<input placeholder="Email" />

Associate errors with fields

<label for="email">Email</label>
<input
  id="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-hint email-err"
/>
<p id="email-hint">We’ll never share your email.</p>
<p id="email-err" role="alert">Enter a valid email address.</p>
  • aria-invalid="true" when the field fails validation
  • aria-describedby lists hint + error ids
  • role="alert" or a live region for new errors

Summary errors at top

For multi-field forms, provide a summary that links to fields:

<div role="alert" tabindex="-1" id="form-errors">
  <p>Please fix 2 errors:</p>
  <ul>
    <li><a href="#email">Email is required</a></li>
    <li><a href="#password">Password is too short</a></li>
  </ul>
</div>

Move focus to the summary on failed submit — focus management.

Don’t rely on color alone

Red outline + text message. See do not rely on color alone.

Required fields

<label for="name">Name <span aria-hidden="true">*</span></label>
<input id="name" required aria-required="true" />

Visible required indicator plus programmatic required.

Inline validation timing

  • Prefer validate on submit first for less noise.
  • On blur validation should not steal focus unexpectedly.
  • Announce async server errors via live region or alert.

React sketch

function Field({
  id,
  label,
  error,
  ...props
}: {
  id: string;
  label: string;
  error?: string;
} & React.ComponentPropsWithoutRef<'input'>) {
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-err` : undefined}
        {...props}
      />
      {error ? (
        <p id={`${id}-err`} role="alert">
          {error}
        </p>
      ) : null}
    </div>
  );
}

Footguns

  1. Clickable divs as inputs.
  2. Errors in title tooltips only.
  3. Disabling submit without explaining why.
  4. aria-label duplicating visible label incorrectly.

Interview out-loud answer

“Every input needs a visible label. Errors use text linked via aria-describedby and aria-invalid, not color alone. On failed submit I focus an error summary. Placeholder is never the only label.”

Disabled submit buttons

Disabling Submit until the form is “valid” can hide why the user can’t proceed. Prefer keeping Submit active and showing errors on activation, or pair disabled state with visible helper text: “Enter email and password to continue.”

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Implementation notes

Wire this into real code on the next feature, not only a demo. Prefer the smallest change that encodes the rule — a shared helper, a lint rule, or a checklist item in the PR template. Revisit after a week of production traffic: if users or tests still hit the failure mode, the documentation (and the abstraction) are not sharp enough yet.

Further reading

Related guides