Skip to main content
WCAG Patterns

WCAG 3.3.1 · Level A · WCAG 2.0

Error Identification

Input errors must be identified in text — not just a red border. Describe what field failed and why.

Principle
Understandable
Guideline
Input Assistance
Level
A
Added in
WCAG 2.0

What it really means

When a form input error is detected, the error must be identified and described to the user in text. A red border alone is not enough — colour is not perceivable to everyone, and a border says "something is wrong" without saying what.

The rule doesn't mandate a specific pattern — inline message, error summary, or both work — but the error must be programmatically tied to the input, and its message must be plain text the user can read.

Who it helps

  • Blind screen-reader users — who cannot see the border colour.
  • Low-vision and colour-blind users — who may see the border but cannot distinguish red from grey.
  • Cognitive-disabled users — who need explicit text to understand what went wrong.
  • Everyone under pressure — tired, distracted, on mobile. Plain language wins.

How to test

  1. Submit a form with deliberately bad input. Every error should be:
    1. Described in text.
    2. Linked to its input via aria-describedby or aria-errormessage.
    3. Announced by a screen reader without user action (usually via aria-live or focus move).
  2. Run axe DevTools — aria-valid-attr-value and aria-required-attr catch the wiring, label catches missing labels.
  3. Turn off CSS. All error messages should still be visible.

A failing pattern

// Red border only — screen readers know nothing.
<input
  type="email"
  className={invalid ? "border-red-500" : "border-neutral-300"}
/>
 
// Error in a toast that disappears in 3 seconds — missed by AT users.
if (invalid) toast.error("Invalid email");

A passing pattern

Inline error message associated with the input:

function EmailField() {
  const [value, setValue] = useState("");
  const [error, setError] = useState<string | null>(null);
 
  return (
    <div>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        type="email"
        autoComplete="email"
        value={value}
        aria-invalid={error !== null}
        aria-describedby={error ? "email-error" : undefined}
        onChange={(event) => setValue(event.target.value)}
        onBlur={() => setError(validateEmail(value))}
      />
      {error && (
        <p id="email-error" role="alert" className="text-(--danger)">
          {error}
        </p>
      )}
    </div>
  );
}

The key wiring:

  • aria-invalid="true" tells AT the input is in an error state.
  • aria-describedby="email-error" pulls the message into the input's accessible description so AT reads it after the label.
  • role="alert" on the message element announces it as it appears. Use this carefully — for form-wide feedback, a single error summary at the top with focus moved to it is often calmer.

Error summary pattern (GOV.UK):

On submit, collect all errors into a named region at the top of the form, move focus there, and link each item to the failing input.

{errors.length > 0 && (
  <div
    ref={summaryRef}
    tabIndex={-1}
    role="alert"
    aria-labelledby="error-summary-title"
  >
    <h2 id="error-summary-title">There is a problem</h2>
    <ul>
      {errors.map((err) => (
        <li key={err.field}>
          <a href={`#${err.field}`}>{err.message}</a>
        </li>
      ))}
    </ul>
  </div>
)}

Notes and edge cases

  • aria-errormessage is the newer property that explicitly names the error text. Support across screen readers improved through 2023–2025; aria-describedby still has broader support.
  • Don't aria-live="assertive" on every error message — it cuts off other announcements. Use polite for most form feedback.
  • Don't rely on required attribute alone to communicate required fields. Mark them visibly ("(required)") — not just asterisks.