Files
2025-11-30 08:25:27 +08:00

6.3 KiB

Accessibility (a11y) Best Practices

Complete guide for building accessible forms.


WCAG Compliance

Required Elements

  1. Labels - Every input must have a label
  2. Error Messages - Must be accessible to screen readers
  3. Focus Management - Errors should be announced
  4. Keyboard Navigation - Full keyboard support

ARIA Attributes

Essential ARIA

<input
  id="email"
  type="email"
  {...register('email')}
  aria-invalid={errors.email ? 'true' : 'false'}
  aria-describedby={errors.email ? 'email-error' : 'email-hint'}
  aria-required="true"
/>

<span id="email-hint">We'll never share your email</span>

{errors.email && (
  <span id="email-error" role="alert">
    {errors.email.message}
  </span>
)}

Live Regions for Error Announcements

{Object.keys(errors).length > 0 && (
  <div role="alert" aria-live="assertive" aria-atomic="true">
    Form has {Object.keys(errors).length} errors. Please review.
  </div>
)}

Focus Management

Focus First Error

import { useEffect, useRef } from 'react'

const firstErrorRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  if (Object.keys(errors).length > 0) {
    firstErrorRef.current?.focus()
  }
}, [errors])

// In JSX
<input
  ref={Object.keys(errors)[0] === 'email' ? firstErrorRef : undefined}
  {...register('email')}
/>

Using setFocus

const onSubmit = async (data) => {
  try {
    await submitData(data)
  } catch (error) {
    setFocus('email') // Focus field programmatically
  }
}

Label Association

Explicit Labels

<label htmlFor="email">Email Address</label>
<input id="email" {...register('email')} />

aria-label (When Visual Label Not Possible)

<input
  {...register('search')}
  aria-label="Search products"
  placeholder="Search..."
/>

aria-labelledby (Multiple Labels)

<h3 id="billing-heading">Billing Address</h3>
<input
  {...register('billingStreet')}
  aria-labelledby="billing-heading billing-street-label"
/>
<span id="billing-street-label">Street</span>

Required Fields

Visual Indicator

<label htmlFor="email">
  Email <span aria-label="required">*</span>
</label>
<input
  id="email"
  {...register('email')}
  aria-required="true"
  required
/>

Legend for Required Fields

<p className="required-legend">
  <span aria-label="required">*</span> Required field
</p>

Error Messaging

Accessible Error Pattern

<div>
  <label htmlFor="password">Password</label>
  <input
    id="password"
    type="password"
    {...register('password')}
    aria-invalid={errors.password ? 'true' : 'false'}
    aria-describedby={errors.password ? 'password-error' : 'password-hint'}
  />

  <span id="password-hint" className="hint">
    Must be at least 8 characters
  </span>

  {errors.password && (
    <span id="password-error" role="alert" className="error">
      {errors.password.message}
    </span>
  )}
</div>

Fieldsets and Legends

<fieldset>
  <legend>Contact Information</legend>

  <div>
    <label htmlFor="firstName">First Name</label>
    <input id="firstName" {...register('firstName')} />
  </div>

  <div>
    <label htmlFor="lastName">Last Name</label>
    <input id="lastName" {...register('lastName')} />
  </div>
</fieldset>

Radio Groups

<fieldset>
  <legend>Choose your plan</legend>

  <div>
    <input
      id="plan-basic"
      type="radio"
      value="basic"
      {...register('plan')}
    />
    <label htmlFor="plan-basic">Basic</label>
  </div>

  <div>
    <input
      id="plan-pro"
      type="radio"
      value="pro"
      {...register('plan')}
    />
    <label htmlFor="plan-pro">Pro</label>
  </div>
</fieldset>

Keyboard Navigation

Tab Order

// Ensure logical tab order with tabindex (use sparingly)
<input {...register('email')} tabIndex={1} />
<input {...register('password')} tabIndex={2} />
<button type="submit" tabIndex={3}>Submit</button>
<a href="#main-form" className="skip-link">
  Skip to form
</a>

<form id="main-form">
  {/* ... */}
</form>

Button Accessibility

Submit Button States

<button
  type="submit"
  disabled={isSubmitting}
  aria-busy={isSubmitting ? 'true' : 'false'}
  aria-live="polite"
>
  {isSubmitting ? 'Submitting...' : 'Submit Form'}
</button>

Icon Buttons

<button type="button" aria-label="Remove item" onClick={remove}>
  <TrashIcon aria-hidden="true" />
</button>

Screen Reader Announcements

Status Messages

{isSubmitSuccessful && (
  <div role="status" aria-live="polite">
    Form submitted successfully!
  </div>
)}

Loading States

{isSubmitting && (
  <div role="status" aria-live="polite">
    Submitting form, please wait...
  </div>
)}

Color Contrast

WCAG AA Standards

  • Normal text: 4.5:1 minimum
  • Large text: 3:1 minimum
  • UI components: 3:1 minimum
/* Good contrast examples */
.error {
  color: #c41e3a; /* Red */
  background: #ffffff; /* White */
  /* Contrast ratio: 5.77:1 ✓ */
}

.button {
  color: #ffffff;
  background: #0066cc;
  /* Contrast ratio: 7.33:1 ✓ */
}

Testing

Automated Testing Tools

  • axe DevTools - Browser extension
  • Lighthouse - Chrome DevTools
  • WAVE - Web accessibility evaluation tool

Manual Testing

  1. Keyboard Navigation - Tab through entire form
  2. Screen Reader - Test with NVDA (Windows) or VoiceOver (Mac)
  3. Zoom - Test at 200% zoom
  4. High Contrast - Test in high contrast mode

Accessibility Checklist

  • All inputs have associated labels
  • Required fields are marked with aria-required
  • Error messages use role="alert"
  • Errors have aria-describedby linking to error text
  • Form has clear heading structure
  • Keyboard navigation works completely
  • Focus is managed appropriately
  • Color is not the only indicator of errors
  • Contrast ratios meet WCAG AA standards
  • Screen reader testing completed

Resources: