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.
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>
);
}