Accessibility Developer Kit · Guide 16 / 20

Accessible Error Message

Inline field errors, multi-field forms with a focused summary, urgent toasts via role="alert", and the persistent-live-region pattern for async operations — Vanilla and React.

  • WCAG: 1.4.1 · 3.3.1 · 3.3.3 · 4.1.3
  • Level: Intermediate
  • Icons: lucide-react (AlertCircle, AlertTriangle, X)
1

What we are building

Error messages that are seen AND heard, with the right level of urgency.

What we are building

There are four error-UX patterns you need. For a single field: inline text + icon + aria-invalid + aria-describedby. For a form submit: role="alert" summary that takes focus. For urgent system errors: a toast with role="alert". For async flows: a persistent live region you write status into.

The matching WCAG rules: 3.3.1 identifies the error in text, 3.3.3 suggests how to fix it, 1.4.1 says color alone is not enough, 4.1.3 says status messages must be programmatically determinable.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Inline field error

Preferred
Live example

Type a bad address and Tab away. Error gets icon + text, red border, aria-invalid.

HTML · Accessible
<!-- aria-invalid + aria-describedby + text + icon -->
<label for="email">Email address</label>
<input
  id="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-err"
  autocomplete="email"
>
<span id="email-err" class="field-err">
  <svg aria-hidden="true"><!-- alert icon --></svg>
  Enter an address like <strong>you@example.com</strong>.
</span>

Text + icon + aria-invalid + aria-describedby. Never red border alone — fails 1.4.1 and 3.3.1.

Variant B

Multi-field form (focus first invalid)

Scales to long forms
Live example

Submit empty — focus jumps to the first invalid field. No summary wall. SR reads: “Full name, invalid, edit, Full name is required.”

HTML · Accessible
<!-- Focus the FIRST invalid field (not a top summary).
     Each field has inline error linked via aria-describedby.
     SR reads the error when focus lands on the field.
     Scales to forms of any length — no wall-of-errors. -->
<form noValidate>
  <label for="f-name">Full name <span aria-hidden="true">*</span></label>
  <input id="f-name" type="text" required autocomplete="name"
         aria-invalid="true" aria-describedby="f-name-err">
  <span id="f-name-err">Full name is required.</span>

  <label for="f-email">Email <span aria-hidden="true">*</span></label>
  <input id="f-email" type="email" required autocomplete="email"
         aria-invalid="true" aria-describedby="f-email-err">
  <span id="f-email-err">Email is required.</span>

  <!-- ...more fields... -->

  <button type="submit">Create account</button>
</form>

<script>
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    // ... validate fields, set aria-invalid, render inline errors ...
    const firstInvalid = form.querySelector('[aria-invalid="true"]');
    firstInvalid?.focus();   // SR reads label + "invalid" + describedby text
  });
</script>

<!-- Alternative (GOV.UK) pattern: show a summary at top with
     role="alert" + tabindex="-1" and focus the summary. Good for
     short forms (3-5 fields). Avoid on long forms. -->

Focus jumps to the FIRST invalid field; SR reads its error on focus. No wall-of-errors summary — works for forms of any length. Short forms can still use the top-summary (GOV.UK) pattern.

Variant C

Toast with role=alert

Urgent only
Live example

role="alert" interrupts SR immediately. Reserve for critical errors; use role="status" for success.

HTML · Accessible
<!-- role="alert" announces IMMEDIATELY to SR -->
<div role="alert" class="toast toast--err">
  <svg aria-hidden="true"><!-- alert icon --></svg>
  <span>Payment failed. Your card was declined.</span>
  <button type="button" aria-label="Dismiss alert">×</button>
</div>

<!-- For success: role="status" (polite) instead of alert (assertive) -->
<div role="status" class="toast toast--ok">Saved.</div>

role=alert interrupts SR immediately. Reserve for critical errors. Use role=status for success.

Variant D

Async live region

Preferred
Live example

Clicking save flips between success + error 50/50. The live region announces each.

HTML · Accessible
<!-- Persistent live region catches updates -->
<div id="status" role="status" aria-live="polite" class="live"></div>

<script>
  const status = document.getElementById('status');

  async function save() {
    status.textContent = 'Saving...';           // polite announce
    try {
      await fetch('/api/save', { method: 'POST' });
      status.textContent = 'Saved.';
    } catch {
      status.textContent = 'Error: could not save. Try again.';
    }
  }
</script>

<!-- Writing to a persistent live region announces reliably across browsers.
     Create / destroy on the fly is less reliable than toggling text. -->

Persistent polite live region catches save/upload/retry statuses. Writing to it announces reliably across browsers.

3

Flagship implementation

A reusable error-region provider + inline FieldError component.

Flagship implementation

AccessibleErrorMessage.jsx
import React, { useRef, useState } from "react";
import "./AccessibleErrorMessage.css";

/**
 * useErrorRegion — one app-level live region for async statuses + errors.
 *
 * Usage: wrap your app with <ErrorRegionProvider /> and call
 * useErrorRegion().announce('Saved.') from anywhere.
 */
const ErrorRegionContext = React.createContext({ announce: () => {} });

export function ErrorRegionProvider({ children }) {
  const [msg, setMsg] = useState("");
  const [kind, setKind] = useState("status");   // "status" | "alert"

  const api = React.useMemo(() => ({
    announce: (text, { urgent = false } = {}) => {
      setKind(urgent ? "alert" : "status");
      setMsg("");
      // Force a change so SR re-announces identical messages
      setTimeout(() => setMsg(text), 10);
    },
  }), []);

  return (
    <ErrorRegionContext.Provider value={api}>
      {children}
      <div
        role={kind}
        aria-live={kind === "alert" ? "assertive" : "polite"}
        className="sr-only"
      >
        {msg}
      </div>
    </ErrorRegionContext.Provider>
  );
}

export function useErrorRegion() {
  return React.useContext(ErrorRegionContext);
}

/**
 * FieldError — inline error with icon, aria-invalid wiring is the caller's job.
 */
export function FieldError({ id, children }) {
  return (
    <span id={id} className="field-err">
      <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24"
           fill="none" stroke="currentColor" strokeWidth="2">
        <circle cx="12" cy="12" r="10"/>
        <line x1="12" y1="8"  x2="12" y2="12"/>
        <line x1="12" y1="16" x2="12.01" y2="16"/>
      </svg>
      {children}
    </span>
  );
}
Usage · App.jsx
import { useState } from "react";
import { ErrorRegionProvider, useErrorRegion, FieldError } from "./AccessibleErrorMessage";

function SignupForm() {
  const [email, setEmail] = useState("");
  const [err, setErr] = useState("");
  const { announce } = useErrorRegion();

  async function submit(e) {
    e.preventDefault();
    if (!/^\S+@\S+\.\S+$/.test(email)) {
      setErr("Enter an address like you@example.com.");
      return;
    }
    setErr("");
    try {
      await fetch("/api/signup", { method: "POST", body: JSON.stringify({ email }) });
      announce("Account created.");
    } catch {
      announce("Could not create account. Try again.", { urgent: true });
    }
  }

  return (
    <form noValidate onSubmit={submit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" value={email}
             onChange={e => setEmail(e.target.value)}
             aria-invalid={!!err || undefined}
             aria-describedby={err ? "email-err" : undefined} />
      {err && <FieldError id="email-err">{err}</FieldError>}
      <button type="submit">Sign up</button>
    </form>
  );
}

export default function App() {
  return (
    <ErrorRegionProvider>
      <SignupForm />
    </ErrorRegionProvider>
  );
}
4

Keyboard behavior

Error UX is mostly SR-driven; keyboard behavior is about focus movement.

Keyboard behavior

ActionExpected result
Field blurInline validation runs; error appears below input
Submit with errorsFocus moves to the error summary (role=alert)
Click error link in summaryFocus moves to the field with the error
Toast appearsFocus NOT stolen — toast is informational
Toast dismiss EnterRemoves the toast; focus returns to origin
5

Screen reader expectations

role=alert interrupts; role=status waits for a break.

Screen reader expectations

  • Inline error (described-by): “Email, invalid, edit, Enter an address like you@example.com”
  • Error summary focused: “Alert, There are 2 problems with your submission, link, Email is required...”
  • Toast (role=alert): interrupts current reading with “Alert, Payment failed. Your card was declined.”
  • Live region (role=status): waits for a pause: “Saved.” or “Error: couldn’t save. Try again.”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.4.1 Use of Color (A)Errors must use more than color — add text and/or icon.
3.3.1 Error Identification (A)Error must be identified in text, linked to its field.
3.3.3 Error Suggestion (AA)Suggest how to fix it (format example, allowed length).
4.1.3 Status Messages (AA)Status must be programmatically determinable (role/aria-live) without receiving focus.
1.4.11 Non-text Contrast (AA)Red border / icon must have 3:1 contrast with background.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: trigger error → assert aria-invalid + accessible description → for toasts, assert role=alert text appears → for summary, assert focus moved.

Jest + Testing Library + jest-axe

AccessibleErrorMessage.test.jsx
// Jest + @testing-library/react + jest-axe
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import { FieldError } from "./AccessibleErrorMessage";

expect.extend(toHaveNoViolations);

function Fixture() {
  const [v, setV] = useState("");
  const err = v && !/^\S+@\S+\.\S+$/.test(v) ? "Enter a valid email." : "";
  return (
    <>
      <label htmlFor="e">Email</label>
      <input
        id="e" value={v}
        onChange={(x) => setV(x.target.value)}
        aria-invalid={!!err || undefined}
        aria-describedby={err ? "e-err" : undefined}
      />
      {err && <FieldError id="e-err">{err}</FieldError>}
    </>
  );
}

test("invalid input exposes aria-invalid + accessible description", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.type(screen.getByLabelText(/email/i), "nope");
  const input = screen.getByLabelText(/email/i);
  expect(input).toHaveAttribute("aria-invalid", "true");
  expect(input).toHaveAccessibleDescription(/enter a valid email/i);
});

test("has no axe violations", async () => {
  const { container } = render(<Fixture />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleErrorMessage.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorRegionProvider, useErrorRegion } from "./AccessibleErrorMessage";

function Trigger() {
  const { announce } = useErrorRegion();
  return <button onClick={() => announce("Payment failed.", { urgent: true })}>Fail</button>;
}

test("urgent announcements use role=alert", async () => {
  const user = (await import("@testing-library/user-event")).default.setup();
  render(<ErrorRegionProvider><Trigger /></ErrorRegionProvider>);
  await user.click(screen.getByRole("button", { name: "Fail" }));
  // Wait a tick for the state flip
  await new Promise((r) => setTimeout(r, 20));
  expect(await screen.findByRole("alert")).toHaveTextContent(/payment failed/i);
});

Playwright + @axe-core/playwright

error-message.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("error page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-error-message");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("submit with empty email surfaces inline error", async ({ page }) => {
  await page.goto("/en/accessible-error-message");
  const input = page.getByLabel(/email address/i).first();
  await input.fill("nope");
  await input.blur();
  await expect(input).toHaveAttribute("aria-invalid", "true");
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Error text is identified by words — not color alone
  • Error icon is aria-hidden="true" (text carries the meaning)
  • Failing input has aria-invalid="true"
  • Error text linked via aria-describedby (space-separated list if multiple)
  • Error suggests how to fix the problem (3.3.3)
  • On invalid submit, focus moves to the error summary
  • Toast for critical errors uses role="alert"
  • Toast for success uses role="status" (polite)
  • Live region exists in DOM before text is written (not created on the fly)
  • Zero critical / serious axe violations
9

Common mistakes

The error bugs we see on nearly every audit.

Common mistakes

  • Red border, no text. Fails 1.4.1 and 3.3.1 — SR users and color-blind users miss it.
  • Error text not linked. Visible but not announced with the input.
  • role=alert for every notification. Reserve for truly urgent; use role=status for success.
  • Creating the live region dynamically. SR sometimes misses the first update. Keep it in the DOM from page load.
  • Submit shows errors but focus stays on the button. SR users don’t know anything happened.
  • Vague error ("Invalid."). Say what’s wrong and how to fix it.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo