A form that displays validation errors as inline red text — but does not announce them to assistive technology — leaves screen reader users in a silent loop: they submit the form, receive no feedback, and submit again, never understanding why their submission fails. WCAG 2.2 SC 3.3.1 (Error Identification) requires error fields to be identified programmatically. SC 3.3.3 (Error Suggestion) requires that suggestions for correction be provided when the format is known. SC 4.1.3 (Status Messages) requires status messages to be programmatically determinable. Section 508 2018 Refresh 502.3.14 covers status change announcements. The aria-describedby link between an error message and its input is the mechanism that allows a screen reader to read the error when the user focuses the invalid field.
High because inaccessible error communication completely prevents screen reader users from diagnosing and correcting form submission failures.
Implement a two-part error communication strategy: an aria-live error summary that announces on submission failure, and per-field aria-describedby links that surface the error when the field receives focus.
const ContactForm = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
const summaryRef = useRef<HTMLDivElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email address is required.';
if (!message) newErrors.message = 'Message cannot be empty.';
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
// Move focus to summary so screen reader announces it
summaryRef.current?.focus();
}
};
return (
<form onSubmit={handleSubmit} noValidate>
{Object.keys(errors).length > 0 && (
<div
ref={summaryRef}
role="alert"
aria-live="assertive"
tabIndex={-1}
>
<h2>Please fix {Object.keys(errors).length} error(s):</h2>
<ul>
{Object.entries(errors).map(([field, msg]) => (
<li key={field}><a href={`#${field}`}>{msg}</a></li>
))}
</ul>
</div>
)}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
<button type="submit">Send</button>
</form>
);
};
ID: accessibility-basics.forms-interactive.form-error-communication
Severity: high
What to look for: Enumerate every relevant item. Check how form validation errors are presented. Errors should be announced to screen readers via aria-live or by moving focus to an error summary. Error messages should be associated with specific fields via aria-describedby. Check that error messages appear in the DOM, not just visually.
Pass criteria: At least 1 of the following conditions is met. Form submission errors are announced via aria-live or focus moves to error summary. Error messages are associated with fields via aria-describedby. Errors are visible and programmatically perceivable.
Fail criteria: Errors appear only visually with no screen reader announcement. Error messages are not associated with specific fields. Focus does not move to error summary.
Skip (N/A) when: The application has no forms.
Detail on fail: Describe the error communication issue. Example: "Form validation errors appear as inline red text but are not announced to screen readers. No aria-live on error messages. No aria-describedby on invalid inputs."
Remediation: Communicate form errors accessibly:
const LoginForm = () => {
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
const newErrors = {};
if (!email) newErrors.email = 'Email is required';
if (!password) newErrors.password = 'Password is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Announce errors to screen readers
document.getElementById('error-summary')?.focus();
}
};
return (
<form onSubmit={handleSubmit}>
{Object.keys(errors).length > 0 && (
<div
id="error-summary"
role="alert"
aria-live="assertive"
tabIndex={-1}
>
<h2>Please correct the following errors:</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>{message}</li>
))}
</ul>
</div>
)}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" className="error">
{errors.email}
</p>
)}
</div>
<button type="submit">Login</button>
</form>
);
};