View contents
Error Identification Implementation Guide: Complete Patterns for Accessible Form Validation
Introduction
Accessible error handling is critical for form usability. This guide covers complete implementation patterns for error identification, error suggestions, and focus management that meet WCAG 3.3.1 and 3.3.3 requirements while providing excellent user experience for everyone.
Beyond accessibility compliance, well-implemented error handling reduces form abandonment and support requests by helping users successfully complete forms on the first try.
Complete Form Validation Pattern
HTML Structure with All ARIA Attributes
<form id="registration-form" novalidate>
<!-- Error Summary (shown after submission with errors) -->
<div
id="error-summary"
class="error-summary"
role="alert"
aria-labelledby="error-summary-title"
tabindex="-1"
hidden
>
<h2 id="error-summary-title">
<svg aria-hidden="true" class="error-icon"><!-- icon --></svg>
Please fix the following errors:
</h2>
<ul id="error-list">
<!-- Error links inserted dynamically -->
</ul>
</div>
<!-- Form Fields -->
<div class="form-group">
<label for="name">
Full Name
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
autocomplete="name"
>
<p id="name-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="email">
Email Address
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-hint"
autocomplete="email"
>
<p id="email-hint" class="hint">We'll never share your email</p>
<p id="email-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
pattern="[0-9]{10}"
aria-describedby="phone-hint"
autocomplete="tel"
>
<p id="phone-hint" class="hint">10 digits, numbers only</p>
<p id="phone-error" class="error-message" hidden></p>
</div>
<button type="submit">Create Account</button>
</form>
CSS Styling for Error States
/* Error summary container */
.error-summary {
background-color: #fef2f2;
border: 2px solid #dc2626;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.error-summary:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
.error-summary h2 {
color: #dc2626;
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.error-summary ul {
margin: 0;
padding-left: 24px;
}
.error-summary li {
margin-bottom: 4px;
}
.error-summary a {
color: #dc2626;
text-decoration: underline;
}
.error-summary a:hover,
.error-summary a:focus {
color: #991b1b;
}
/* Form group with error */
.form-group.has-error input,
.form-group.has-error select,
.form-group.has-error textarea {
border-color: #dc2626;
box-shadow: 0 0 0 1px #dc2626;
}
.form-group.has-error input:focus,
.form-group.has-error select:focus,
.form-group.has-error textarea:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
box-shadow: 0 0 0 1px #dc2626;
}
/* Error message styling */
.error-message {
color: #dc2626;
font-size: 0.875rem;
margin-top: 4px;
display: flex;
align-items: flex-start;
gap: 6px;
}
.error-message::before {
content: "⚠";
flex-shrink: 0;
}
/* Required indicator */
.required {
color: #dc2626;
margin-left: 2px;
}
/* Hint text */
.hint {
color: #6b7280;
font-size: 0.875rem;
margin-top: 4px;
}
/* Hidden state */
[hidden] {
display: none !important;
}
JavaScript Validation Implementation
Complete Validation Class
class FormValidator {
constructor(formElement, options = {}) {
this.form = formElement;
this.options = {
validateOnBlur: true,
validateOnInput: false,
showErrorSummary: true,
focusFirstError: true,
...options
};
this.errors = new Map();
this.validators = new Map();
this.init();
}
init() {
// Prevent browser validation
this.form.setAttribute('novalidate', '');
// Set up event listeners
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
if (this.options.validateOnBlur) {
this.form.addEventListener('blur', (e) => {
if (e.target.matches('input, select, textarea')) {
this.validateField(e.target);
}
}, true);
}
if (this.options.validateOnInput) {
this.form.addEventListener('input', (e) => {
if (e.target.matches('input, select, textarea')) {
// Debounce input validation
clearTimeout(e.target.validationTimeout);
e.target.validationTimeout = setTimeout(() => {
this.validateField(e.target);
}, 300);
}
});
}
// Clear error when user starts typing (after initial validation)
this.form.addEventListener('input', (e) => {
if (e.target.matches('input, select, textarea') &&
this.errors.has(e.target.id)) {
this.clearFieldError(e.target);
}
});
}
// Add custom validator for a field
addValidator(fieldId, validator, message) {
if (!this.validators.has(fieldId)) {
this.validators.set(fieldId, []);
}
this.validators.get(fieldId).push({ validator, message });
}
validateField(field) {
const errors = [];
// Required validation
if (field.hasAttribute('required') && !field.value.trim()) {
errors.push(this.getRequiredMessage(field));
}
// Type validation (email, url, etc.)
if (field.value && field.type === 'email' && !this.isValidEmail(field.value)) {
errors.push('Please enter a valid email address (e.g., [email protected])');
}
// Pattern validation
if (field.value && field.pattern) {
const regex = new RegExp(`^${field.pattern}$`);
if (!regex.test(field.value)) {
errors.push(field.title || 'Please match the requested format');
}
}
// Min/max length validation
if (field.value && field.minLength > 0 && field.value.length < field.minLength) {
errors.push(`Please enter at least ${field.minLength} characters`);
}
if (field.value && field.maxLength > 0 && field.value.length > field.maxLength) {
errors.push(`Please enter no more than ${field.maxLength} characters`);
}
// Custom validators
const customValidators = this.validators.get(field.id) || [];
for (const { validator, message } of customValidators) {
if (!validator(field.value, field)) {
errors.push(message);
}
}
// Update field state
if (errors.length > 0) {
this.showFieldError(field, errors[0]);
return false;
} else {
this.clearFieldError(field);
return true;
}
}
getRequiredMessage(field) {
const label = this.getFieldLabel(field);
return `${label} is required`;
}
getFieldLabel(field) {
const label = this.form.querySelector(`label[for="${field.id}"]`);
if (label) {
// Get text content without the required indicator
return label.textContent.replace('*', '').trim();
}
return field.name || field.id;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
showFieldError(field, message) {
const formGroup = field.closest('.form-group');
const errorElement = document.getElementById(`${field.id}-error`);
// Update error state
this.errors.set(field.id, message);
// Update field attributes
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, `${field.id}-error`));
// Update form group
formGroup?.classList.add('has-error');
// Show error message
if (errorElement) {
errorElement.textContent = message;
errorElement.hidden = false;
}
}
clearFieldError(field) {
const formGroup = field.closest('.form-group');
const errorElement = document.getElementById(`${field.id}-error`);
// Clear error state
this.errors.delete(field.id);
// Update field attributes
field.removeAttribute('aria-invalid');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, null));
// Update form group
formGroup?.classList.remove('has-error');
// Hide error message
if (errorElement) {
errorElement.textContent = '';
errorElement.hidden = true;
}
}
buildDescribedBy(field, errorId) {
const hintId = `${field.id}-hint`;
const hintExists = document.getElementById(hintId);
const ids = [];
if (hintExists) ids.push(hintId);
if (errorId && this.errors.has(field.id)) ids.push(errorId);
return ids.length > 0 ? ids.join(' ') : null;
}
handleSubmit(e) {
e.preventDefault();
// Validate all fields
const fields = this.form.querySelectorAll('input, select, textarea');
let isValid = true;
fields.forEach(field => {
if (!this.validateField(field)) {
isValid = false;
}
});
if (!isValid) {
this.showErrorSummary();
return;
}
// Form is valid - submit
this.hideErrorSummary();
this.form.submit();
}
showErrorSummary() {
if (!this.options.showErrorSummary) {
if (this.options.focusFirstError) {
this.focusFirstError();
}
return;
}
const summary = document.getElementById('error-summary');
const errorList = document.getElementById('error-list');
if (!summary || !errorList) return;
// Build error list
errorList.innerHTML = '';
this.errors.forEach((message, fieldId) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${fieldId}`;
link.textContent = message;
link.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(fieldId)?.focus();
});
li.appendChild(link);
errorList.appendChild(li);
});
// Show and focus summary
summary.hidden = false;
summary.focus();
// Scroll to summary
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
hideErrorSummary() {
const summary = document.getElementById('error-summary');
if (summary) {
summary.hidden = true;
}
}
focusFirstError() {
const firstErrorField = this.form.querySelector('[aria-invalid="true"]');
if (firstErrorField) {
firstErrorField.focus();
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
// Usage
const form = document.getElementById('registration-form');
const validator = new FormValidator(form);
// Add custom validator
validator.addValidator('phone',
(value) => !value || /^\d{10}$/.test(value),
'Phone number must be exactly 10 digits'
);
React Implementation
Accessible Form Component
import React, { useState, useRef, useEffect } from 'react';
interface FormErrors {
[key: string]: string;
}
interface FormData {
name: string;
email: string;
phone: string;
}
function AccessibleForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
phone: ''
});
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const validateField = (name: string, value: string): string | null => {
switch (name) {
case 'name':
if (!value.trim()) return 'Full name is required';
if (value.length < 2) return 'Name must be at least 2 characters';
return null;
case 'email':
if (!value.trim()) return 'Email address is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email (e.g., [email protected])';
}
return null;
case 'phone':
if (value && !/^\d{10}$/.test(value)) {
return 'Phone must be 10 digits';
}
return null;
default:
return null;
}
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key as keyof FormData]);
if (error) {
newErrors[key] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const error = validateField(name, value);
setErrors(prev => {
if (error) {
return { ...prev, [name]: error };
}
const { [name]: removed, ...rest } = prev;
return rest;
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing (only after initial validation)
if (errors[name]) {
setErrors(prev => {
const { [name]: removed, ...rest } = prev;
return rest;
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
if (validateForm()) {
console.log('Form submitted:', formData);
}
};
// Focus error summary when errors exist after submission
useEffect(() => {
if (submitted && Object.keys(errors).length > 0 && errorSummaryRef.current) {
errorSummaryRef.current.focus();
}
}, [errors, submitted]);
const errorCount = Object.keys(errors).length;
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error Summary */}
{errorCount > 0 && submitted && (
<div
ref={errorSummaryRef}
className="error-summary"
role="alert"
tabIndex={-1}
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}:
</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a
href={`#${field}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(field)?.focus();
}}
>
{message}
</a>
</li>
))}
</ul>
</div>
)}
{/* Name Field */}
<div className={`form-group ${errors.name ? 'has-error' : ''}`}>
<label htmlFor="name">
Full Name <span aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
aria-required="true"
aria-invalid={errors.name ? 'true' : undefined}
aria-describedby={errors.name ? 'name-error' : undefined}
autoComplete="name"
/>
{errors.name && (
<p id="name-error" className="error-message" role="alert">
{errors.name}
</p>
)}
</div>
{/* Email Field */}
<div className={`form-group ${errors.email ? 'has-error' : ''}`}>
<label htmlFor="email">
Email Address <span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
aria-required="true"
aria-invalid={errors.email ? 'true' : undefined}
aria-describedby={`email-hint${errors.email ? ' email-error' : ''}`}
autoComplete="email"
/>
<p id="email-hint" className="hint">We'll never share your email</p>
{errors.email && (
<p id="email-error" className="error-message" role="alert">
{errors.email}
</p>
)}
</div>
{/* Phone Field */}
<div className={`form-group ${errors.phone ? 'has-error' : ''}`}>
<label htmlFor="phone">Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={errors.phone ? 'true' : undefined}
aria-describedby={`phone-hint${errors.phone ? ' phone-error' : ''}`}
autoComplete="tel"
/>
<p id="phone-hint" className="hint">10 digits, numbers only</p>
{errors.phone && (
<p id="phone-error" className="error-message" role="alert">
{errors.phone}
</p>
)}
</div>
<button type="submit">Create Account</button>
</form>
);
}
export default AccessibleForm;
Vue 3 Implementation
Composable for Form Validation
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface FormData {
name: string
email: string
phone: string
}
interface FormErrors {
[key: string]: string
}
const formData = ref<FormData>({
name: '',
email: '',
phone: ''
})
const errors = ref<FormErrors>({})
const submitted = ref(false)
const errorSummaryRef = ref<HTMLDivElement | null>(null)
const errorCount = computed(() => Object.keys(errors.value).length)
function validateField(name: keyof FormData, value: string): string | null {
switch (name) {
case 'name':
if (!value.trim()) return 'El nombre completo es requerido'
if (value.length < 2) return 'El nombre debe tener al menos 2 caracteres'
return null
case 'email':
if (!value.trim()) return 'El correo electrónico es requerido'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Ingresa un correo válido (ej., [email protected])'
}
return null
case 'phone':
if (value && !/^\d{10}$/.test(value)) {
return 'El teléfono debe tener 10 dígitos'
}
return null
default:
return null
}
}
function validateForm(): boolean {
const newErrors: FormErrors = {}
for (const key of Object.keys(formData.value) as (keyof FormData)[]) {
const error = validateField(key, formData.value[key])
if (error) {
newErrors[key] = error
}
}
errors.value = newErrors
return Object.keys(newErrors).length === 0
}
function handleBlur(e: FocusEvent) {
const target = e.target as HTMLInputElement
const name = target.name as keyof FormData
const error = validateField(name, target.value)
if (error) {
errors.value = { ...errors.value, [name]: error }
} else {
const { [name]: removed, ...rest } = errors.value
errors.value = rest
}
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
const name = target.name as keyof FormData
// Clear error when user types
if (errors.value[name]) {
const { [name]: removed, ...rest } = errors.value
errors.value = rest
}
}
async function handleSubmit() {
submitted.value = true
if (validateForm()) {
console.log('Form submitted:', formData.value)
} else {
await nextTick()
errorSummaryRef.value?.focus()
}
}
function focusField(fieldId: string) {
document.getElementById(fieldId)?.focus()
}
function getAriaDescribedBy(fieldName: string, hasHint: boolean = false): string | undefined {
const ids: string[] = []
if (hasHint) ids.push(`${fieldName}-hint`)
if (errors.value[fieldName]) ids.push(`${fieldName}-error`)
return ids.length > 0 ? ids.join(' ') : undefined
}
</script>
<template>
<form @submit.prevent="handleSubmit" novalidate>
<!-- Error Summary -->
<div
v-if="errorCount > 0 && submitted"
ref="errorSummaryRef"
class="error-summary"
role="alert"
tabindex="-1"
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
Por favor corrige {{ errorCount }} error{{ errorCount !== 1 ? 'es' : '' }}:
</h2>
<ul>
<li v-for="(message, field) in errors" :key="field">
<a
:href="`#${field}`"
@click.prevent="focusField(field as string)"
>
{{ message }}
</a>
</li>
</ul>
</div>
<!-- Name Field -->
<div :class="['form-group', { 'has-error': errors.name }]">
<label for="name">
Nombre Completo <span aria-hidden="true">*</span>
</label>
<input
v-model="formData.name"
type="text"
id="name"
name="name"
@blur="handleBlur"
@input="handleInput"
aria-required="true"
:aria-invalid="errors.name ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('name')"
autocomplete="name"
>
<p v-if="errors.name" id="name-error" class="error-message" role="alert">
{{ errors.name }}
</p>
</div>
<!-- Email Field -->
<div :class="['form-group', { 'has-error': errors.email }]">
<label for="email">
Correo Electrónico <span aria-hidden="true">*</span>
</label>
<input
v-model="formData.email"
type="email"
id="email"
name="email"
@blur="handleBlur"
@input="handleInput"
aria-required="true"
:aria-invalid="errors.email ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('email', true)"
autocomplete="email"
>
<p id="email-hint" class="hint">Nunca compartiremos tu correo</p>
<p v-if="errors.email" id="email-error" class="error-message" role="alert">
{{ errors.email }}
</p>
</div>
<!-- Phone Field -->
<div :class="['form-group', { 'has-error': errors.phone }]">
<label for="phone">Número de Teléfono</label>
<input
v-model="formData.phone"
type="tel"
id="phone"
name="phone"
@blur="handleBlur"
@input="handleInput"
:aria-invalid="errors.phone ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('phone', true)"
autocomplete="tel"
>
<p id="phone-hint" class="hint">10 dígitos, solo números</p>
<p v-if="errors.phone" id="phone-error" class="error-message" role="alert">
{{ errors.phone }}
</p>
</div>
<button type="submit">Crear Cuenta</button>
</form>
</template>
Automated Testing
Playwright Tests for Error Identification
import { test, expect } from '@playwright/test';
test.describe('Form Error Identification', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/form');
});
test('shows error summary with links after invalid submission', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Error summary should appear
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toBeVisible();
// Should have role="alert" for screen reader announcement
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Should be focused for immediate screen reader access
await expect(errorSummary).toBeFocused();
// Should contain links to error fields
const errorLinks = errorSummary.locator('a');
const linkCount = await errorLinks.count();
expect(linkCount).toBeGreaterThan(0);
// Each link should navigate to its field
const firstLink = errorLinks.first();
await firstLink.click();
const nameInput = page.locator('#name');
await expect(nameInput).toBeFocused();
});
test('inline errors have proper ARIA attributes', async ({ page }) => {
// Submit empty form to trigger errors
await page.click('button[type="submit"]');
// Check required field has aria-invalid
const nameInput = page.locator('#name');
await expect(nameInput).toHaveAttribute('aria-invalid', 'true');
// Check aria-describedby points to error message
const describedBy = await nameInput.getAttribute('aria-describedby');
expect(describedBy).toContain('name-error');
// Error message should be visible
const errorMessage = page.locator('#name-error');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).not.toBeEmpty();
});
test('error messages describe specific problems', async ({ page }) => {
// Enter invalid email
await page.fill('#email', 'not-an-email');
await page.locator('#email').blur();
// Error should be specific
const emailError = page.locator('#email-error');
await expect(emailError).toBeVisible();
const errorText = await emailError.textContent();
expect(errorText).toContain('valid email');
expect(errorText).toContain('@'); // Should include format hint
});
test('errors clear when user corrects input', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Verify error exists
const nameInput = page.locator('#name');
await expect(nameInput).toHaveAttribute('aria-invalid', 'true');
// Enter valid value
await page.fill('#name', 'John Doe');
// Error should clear
await expect(nameInput).not.toHaveAttribute('aria-invalid');
const nameError = page.locator('#name-error');
await expect(nameError).toBeHidden();
});
test('error suggestions provide actionable guidance', async ({ page }) => {
// Enter phone with wrong format
await page.fill('#phone', '12345');
await page.locator('#phone').blur();
// Error should suggest correct format
const phoneError = page.locator('#phone-error');
await expect(phoneError).toBeVisible();
const errorText = await phoneError.textContent();
expect(errorText).toMatch(/10|digits/i); // Should mention format
});
test('screen reader receives error announcements', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Error summary should be announced (role="alert" + aria-live)
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Inline errors should also be announced
const emailError = page.locator('#email-error[role="alert"]');
// Role alert implies aria-live="assertive"
await expect(emailError).toBeVisible();
});
});
Summary Checklist
- [ ] Error summary appears at top of form with role=“alert”
- [ ] Error summary receives focus after form submission with errors
- [ ] Error summary contains links to each field with errors
- [ ] Each error message is descriptive and actionable
- [ ] Fields with errors have aria-invalid=“true”
- [ ] Fields with errors have aria-describedby pointing to error message
- [ ] Error messages appear near their associated fields
- [ ] Errors are described in text (not just color or icons)
- [ ] Error suggestions provide format hints when known
- [ ] Errors clear when user corrects input
- [ ] Focus management helps users navigate to errors
- [ ] Screen readers announce errors when they appear