Detailed guide

Error Identification Implementation Guide

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

References

  1. W3C - WCAG 2.2 SC 3.3.1 Error Identification
  2. W3C - WCAG 2.2 SC 3.3.3 Error Suggestion
  3. W3C - WAI Forms Tutorial - Validating Input
  4. W3C - ARIA21 Using aria-invalid
  5. WebAIM - Accessible Form Validation

Related articles

Related version

Introduction

Error Identification Explained

When users make mistakes filling out forms, they need clear information about what went wrong and how to fix it

Category hub

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities