Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Validate forms accessibly

Forms provide clear validation feedback with accessible error messages and proper ARIA attributes.

Utilities
Quick take
Typical fix time 25 min
  • Associate error messages with fields using aria-describedby
  • Use aria-invalid to indicate validation state
  • Mark required fields programmatically with `required` or `aria-required`
  • Provide inline error messages near the field
  • Don't rely solely on color to indicate errors
  • Keep help text and error text linked to the field throughout validation
Why it matters: Inaccessible form validation leaves screen reader users unable to understand what went wrong and how to fix it, causing frustration and form abandonment.

Rule Details

Accessible form validation ensures all users can understand and correct input errors.

Code Example

<form novalidate>
  <div class="form-field">
    <label for="email">Email address</label>
    <input
      type="email"
      id="email"
      name="email"
      aria-describedby="email-help email-error"
      aria-invalid="false"
      required
    />
    <span id="email-help" class="form-field__help">
      We'll never share your email
    </span>
    <span id="email-error" class="form-field__error" role="alert" hidden>
      Please enter a valid email address
    </span>
  </div>
 
  <button type="submit">Subscribe</button>
</form>

Why It Matters

Inaccessible form validation leaves screen reader users unable to understand what went wrong and how to fix it, causing frustration and form abandonment.

Validation Requirements

RequirementImplementation
Required staterequired or aria-required="true"
Help text associationaria-describedby linking to helper text
Error associationaria-describedby linking to error
Invalid statearia-invalid="true" on field
Error visibilityDon't use color alone
Focus managementMove focus to first error
Error summaryOptional but helpful

Grouped Controls

Radio buttons and related checkboxes need a shared semantic label so users hear the question before the options:

<fieldset aria-describedby="contact-help">
  <legend>Preferred contact method</legend>
 
  <p id="contact-help">Choose the primary way we should contact you.</p>
 
  <label>
    <input type="radio" name="contact" value="email" required>
    Email
  </label>
 
  <label>
    <input type="radio" name="contact" value="phone">
    Phone
  </label>
</fieldset>

React Form with Validation

import { useState, useRef, useId, FormEvent } from 'react'
 
interface FormFieldProps {
  label: string
  name: string
  type?: string
  required?: boolean
  error?: string
  help?: string
  value: string
  onChange: (value: string) => void
}
 
export function FormField({
  label,
  name,
  type = 'text',
  required = false,
  error,
  help,
  value,
  onChange
}: FormFieldProps) {
  const inputId = useId()
  const helpId = useId()
  const errorId = useId()
 
  const describedBy = [
    help ? helpId : null,
    error ? errorId : null,
  ].filter(Boolean).join(' ') || undefined
 
  return (
    <div className="form-field">
      <label htmlFor={inputId}>
        {label}
        {required && <span aria-hidden="true">*</span>}
      </label>
 
      <input
        id={inputId}
        name={name}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        aria-describedby={describedBy}
        aria-invalid={!!error}
        aria-required={required}
        className={error ? 'form-field__input--error' : ''}
      />
 
      {help && (
        <span id={helpId} className="form-field__help">
          {help}
        </span>
      )}
 
      {error && (
        <span id={errorId} className="form-field__error" role="alert">
          <span className="form-field__error-icon" aria-hidden="true">⚠</span>
          {error}
        </span>
      )}
    </div>
  )
}

Complete Form Example

interface FormData {
  name: string
  email: string
  password: string
}
 
interface FormErrors {
  name?: string
  email?: string
  password?: string
}
 
export function SignupForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    password: ''
  })
  const [errors, setErrors] = useState<FormErrors>({})
  const [submitted, setSubmitted] = useState(false)
  const formRef = useRef<HTMLFormElement>(null)
 
  const validate = (): FormErrors => {
    const newErrors: FormErrors = {}
 
    if (!formData.name.trim()) {
      newErrors.name = 'Name is required'
    }
 
    if (!formData.email.trim()) {
      newErrors.email = 'Email is required'
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = 'Please enter a valid email address'
    }
 
    if (!formData.password) {
      newErrors.password = 'Password is required'
    } else if (formData.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters'
    }
 
    return newErrors
  }
 
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault()
    const newErrors = validate()
    setErrors(newErrors)
    setSubmitted(true)
 
    if (Object.keys(newErrors).length > 0) {
      // Focus first field with error
      const firstErrorField = formRef.current?.querySelector('[aria-invalid="true"]')
      ;(firstErrorField as HTMLElement)?.focus()
      return
    }
 
    // Submit form
    console.log('Form submitted:', formData)
  }
 
  const updateField = (field: keyof FormData) => (value: string) => {
    setFormData(prev => ({ ...prev, [field]: value }))
    // Clear error when user starts typing
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }))
    }
  }
 
  return (
    <form ref={formRef} onSubmit={handleSubmit} noValidate>
      {/* Error summary (optional but helpful) */}
      {submitted && Object.keys(errors).length > 0 && (
        <div role="alert" className="form-errors" aria-live="assertive">
          <h2>Please correct the following errors:</h2>
          <ul>
            {errors.name && <li><a href="#name">{errors.name}</a></li>}
            {errors.email && <li><a href="#email">{errors.email}</a></li>}
            {errors.password && <li><a href="#password">{errors.password}</a></li>}
          </ul>
        </div>
      )}
 
      <FormField
        label="Full name"
        name="name"
        required
        value={formData.name}
        onChange={updateField('name')}
        error={errors.name}
      />
 
      <FormField
        label="Email address"
        name="email"
        type="email"
        required
        value={formData.email}
        onChange={updateField('email')}
        error={errors.email}
        help="We'll never share your email"
      />
 
      <FormField
        label="Password"
        name="password"
        type="password"
        required
        value={formData.password}
        onChange={updateField('password')}
        error={errors.password}
        help="Must be at least 8 characters"
      />
 
      <button type="submit">Create Account</button>
    </form>
  )
}

Real-Time Validation

function useValidation(value: string, validators: ((v: string) => string | null)[]) {
  const [error, setError] = useState<string | null>(null)
  const [touched, setTouched] = useState(false)
 
  const validate = () => {
    for (const validator of validators) {
      const result = validator(value)
      if (result) {
        setError(result)
        return false
      }
    }
    setError(null)
    return true
  }
 
  const onBlur = () => {
    setTouched(true)
    validate()
  }
 
  return {
    error: touched ? error : null,
    onBlur,
    validate,
    isValid: !error
  }
}
 
// Validators
const required = (message: string) => (value: string) =>
  value.trim() ? null : message
 
const minLength = (min: number, message: string) => (value: string) =>
  value.length >= min ? null : message
 
const email = (message: string) => (value: string) =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : message
 
// Usage
function EmailInput() {
  const [email, setEmail] = useState('')
  const validation = useValidation(email, [
    required('Email is required'),
    email('Please enter a valid email')
  ])
 
  return (
    <FormField
      label="Email"
      name="email"
      type="email"
      value={email}
      onChange={(v) => {
        setEmail(v)
        validation.validate()
      }}
      onBlur={validation.onBlur}
      error={validation.error}
    />
  )
}

Styling

.form-field {
  margin-bottom: 1.5rem;
}
 
.form-field label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
}
 
.form-field input {
  width: 100%;
  padding: 0.75rem;
  border: 2px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
}
 
.form-field input:focus {
  outline: none;
  border-color: #0066cc;
  box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
 
.form-field__input--error {
  border-color: #dc3545;
}
 
.form-field__input--error:focus {
  border-color: #dc3545;
  box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
}
 
.form-field__help {
  display: block;
  font-size: 0.875rem;
  color: #666;
  margin-top: 0.25rem;
}
 
.form-field__error {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  font-size: 0.875rem;
  color: #dc3545;
  margin-top: 0.25rem;
}
 
.form-field__error-icon {
  flex-shrink: 0;
}
 
/* Error summary */
.form-errors {
  padding: 1rem;
  background: #f8d7da;
  border: 1px solid #f5c6cb;
  border-radius: 4px;
  margin-bottom: 1.5rem;
}
 
.form-errors h2 {
  font-size: 1rem;
  margin: 0 0 0.5rem;
}
 
.form-errors ul {
  margin: 0;
  padding-left: 1.25rem;
}
 
.form-errors a {
  color: #dc3545;
}

Native HTML Validation

<form>
  <label for="email">Email (required)</label>
  <input
    type="email"
    id="email"
    name="email"
    required
    pattern="[^@]+@[^@]+\.[^@]+"
    title="Please enter a valid email address"
  />
 
  <label for="password">Password (8+ characters)</label>
  <input
    type="password"
    id="password"
    name="password"
    required
    minlength="8"
  />
 
  <button type="submit">Submit</button>
</form>

Verification

Automated Checks

  • Check focus moves to first invalid field
  • Test keyboard-only form completion
  • Check errors clear when user corrects input
  • Check required fields expose required or aria-required in rendered markup
  • Check each aria-describedby reference resolves to visible help or error text

Manual Checks

  • Submit form with empty required fields
  • Verify error messages are announced by screen reader
  • Verify aria-invalid is set correctly
  • Verify error messages are visible without color alone
  • Verify grouped controls announce the <legend> before the option label
  • Verify help text is announced together with the field before and after an error appears

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Verify forms have client-side validation with accessible error messages linked to form fields via aria-describedby. Do not flag React forms just because they omit `method` or `action` when `onSubmit` clearly handles submission client-side. Confirm required fields are exposed programmatically and grouped controls use fieldset and legend.

Fix

Auto-fix issues

Implement validation with clear error messages, aria-invalid states, and aria-describedby references to error text. For client-handled forms, improve validation semantics without forcing server-post patterns that the component is not using. Add `required` or `aria-required` where appropriate and use `<fieldset>` with `<legend>` for grouped choices.

Explain

Learn more

Explain how accessible form validation improves user experience and ensures all users can understand and correct errors.

Review

Code review

Review templates, server-rendered HTML, and shared components that output markup related to Validate forms accessibly. Flag exact elements, attributes, and routes where the rendered HTML violates the rule. Check that help text and error text are both included in aria-describedby and that required state is exposed in markup, not only in visual styling.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Nu Html Checker
validator.w3.orgTool

Rules that often go hand-in-hand with this one.

Make file uploads accessible

File upload components are accessible with proper labels, file type restrictions, and progress feedback.

HTML
Use semantic input type attributes

Set the correct type attribute on input elements to trigger the right mobile keyboard, enable browser validation, and improve autofill accuracy.

HTML
Make search inputs accessible

Search functionality is accessible with proper input type, label, role, and autocomplete suggestions.

HTML
Create accessible tooltips

Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385