Back to Blog

Building Accessible Forms with React Hook Form

Forms are the backbone of user interaction, but they're often the biggest accessibility barrier. React Hook Form makes building performant forms easy—but you still need to add proper accessibility. In this tutorial, you'll learn how to create fully accessible forms with validation, error messages, ARIA labels, and more. Let's build forms that work for everyone!

Why Form Accessibility Matters

Inaccessible forms lock out millions of users:

  • Screen reader users - Need proper labels and error announcements
  • Keyboard users - Must be able to navigate without a mouse
  • Users with cognitive disabilities - Benefit from clear instructions
  • Mobile users - Need large touch targets and proper input types
  • Legal requirement - WCAG 2.1 AA compliance is mandatory in many countries

Setting Up React Hook Form

npm install react-hook-form

// Basic setup
import { useForm } from 'react-hook-form';

function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm();

  const onSubmit = async (data) => {
    console.log(data);
    // Send to API
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  );
}

Building an Accessible Text Input

Every input needs a proper label, validation, and error handling:

import { useId } from 'react';
import { useForm } from 'react-hook-form';

function AccessibleInput({
  label,
  name,
  type = 'text',
  required = false,
  helpText,
  register,
  errors,
  ...rest
}) {
  const id = useId();
  const errorId = `${id}-error`;
  const helpId = `${id}-help`;
  const error = errors[name];

  return (
    <div className="form-field">
      <label htmlFor={id} className="form-label">
        {label}
        {required && (
          <span aria-label="required" className="required">
            *
          </span>
        )}
      </label>

      {helpText && (
        <p id={helpId} className="form-help">
          {helpText}
        </p>
      )}

      <input
        id={id}
        type={type}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={
          error
            ? errorId
            : helpText
            ? helpId
            : undefined
        }
        className={`form-input ${error ? 'error' : ''}`}
        {...register(name, { required })}
        {...rest}
      />

      {error && (
        <p id={errorId} role="alert" className="form-error">
          {error.message}
        </p>
      )}
    </div>
  );
}

// Usage
function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <AccessibleInput
        label="Email Address"
        name="email"
        type="email"
        required
        helpText="We'll never share your email"
        register={register}
        errors={errors}
      />
    </form>
  );
}

Key Accessibility Features:

  • htmlFor connects label to input
  • useId() generates unique IDs
  • aria-required announces required fields
  • aria-invalid indicates validation errors
  • aria-describedby links help text and errors
  • role="alert" announces errors immediately

Complete Registration Form Example

import { useForm } from 'react-hook-form';

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm({
    mode: 'onBlur', // Validate on blur for better UX
  });

  const password = watch('password');

  const onSubmit = async (data) => {
    try {
      await registerUser(data);
      // Show success message
    } catch (error) {
      // Handle error
    }
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      noValidate
      aria-label="Registration form"
    >
      <h2>Create Account</h2>

      {/* Full Name */}
      <div className="form-field">
        <label htmlFor="name">
          Full Name <span aria-label="required">*</span>
        </label>
        <input
          id="name"
          type="text"
          autoComplete="name"
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
          {...register('name', {
            required: 'Name is required',
            minLength: {
              value: 2,
              message: 'Name must be at least 2 characters',
            },
          })}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="error">
            {errors.name.message}
          </p>
        )}
      </div>

      {/* Email */}
      <div className="form-field">
        <label htmlFor="email">
          Email Address <span aria-label="required">*</span>
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : 'email-help'}
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'Invalid email address',
            },
          })}
        />
        <p id="email-help" className="help-text">
          We'll send a verification link to this address
        </p>
        {errors.email && (
          <p id="email-error" role="alert" className="error">
            {errors.email.message}
          </p>
        )}
      </div>

      {/* Password */}
      <div className="form-field">
        <label htmlFor="password">
          Password <span aria-label="required">*</span>
        </label>
        <input
          id="password"
          type="password"
          autoComplete="new-password"
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby={
            errors.password ? 'password-error' : 'password-requirements'
          }
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters',
            },
            pattern: {
              value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
              message: 'Password must contain uppercase, lowercase, and number',
            },
          })}
        />
        <ul id="password-requirements" className="help-text">
          <li>At least 8 characters</li>
          <li>One uppercase letter</li>
          <li>One lowercase letter</li>
          <li>One number</li>
        </ul>
        {errors.password && (
          <p id="password-error" role="alert" className="error">
            {errors.password.message}
          </p>
        )}
      </div>

      {/* Confirm Password */}
      <div className="form-field">
        <label htmlFor="confirmPassword">
          Confirm Password <span aria-label="required">*</span>
        </label>
        <input
          id="confirmPassword"
          type="password"
          autoComplete="new-password"
          aria-required="true"
          aria-invalid={!!errors.confirmPassword}
          aria-describedby={
            errors.confirmPassword ? 'confirm-error' : undefined
          }
          {...register('confirmPassword', {
            required: 'Please confirm your password',
            validate: (value) =>
              value === password || 'Passwords do not match',
          })}
        />
        {errors.confirmPassword && (
          <p id="confirm-error" role="alert" className="error">
            {errors.confirmPassword.message}
          </p>
        )}
      </div>

      {/* Terms and Conditions */}
      <div className="form-field">
        <label className="checkbox-label">
          <input
            type="checkbox"
            aria-required="true"
            aria-invalid={!!errors.terms}
            aria-describedby={errors.terms ? 'terms-error' : undefined}
            {...register('terms', {
              required: 'You must accept the terms',
            })}
          />
          <span>
            I agree to the{' '}
            <a href="/terms" target="_blank" rel="noopener">
              Terms and Conditions
            </a>
          </span>
        </label>
        {errors.terms && (
          <p id="terms-error" role="alert" className="error">
            {errors.terms.message}
          </p>
        )}
      </div>

      {/* Submit Button */}
      <button
        type="submit"
        disabled={isSubmitting}
        aria-busy={isSubmitting}
      >
        {isSubmitting ? 'Creating Account...' : 'Create Account'}
      </button>
    </form>
  );
}

Accessible Select Dropdown

function AccessibleSelect({
  label,
  name,
  options,
  required,
  register,
  errors,
}) {
  const id = useId();
  const error = errors[name];

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-label="required">*</span>}
      </label>

      <select
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={error ? `${id}-error` : undefined}
        {...register(name, { required: required && `${label} is required` })}
      >
        <option value="">Select {label.toLowerCase()}</option>
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>

      {error && (
        <p id={`${id}-error`} role="alert" className="error">
          {error.message}
        </p>
      )}
    </div>
  );
}

// Usage
<AccessibleSelect
  label="Country"
  name="country"
  required
  options={[
    { value: 'us', label: 'United States' },
    { value: 'uk', label: 'United Kingdom' },
    { value: 'ca', label: 'Canada' },
  ]}
  register={register}
  errors={errors}
/>

Accessible Radio Buttons

function AccessibleRadioGroup({
  legend,
  name,
  options,
  required,
  register,
  errors,
}) {
  const groupId = useId();
  const error = errors[name];

  return (
    <fieldset
      className="form-field"
      aria-required={required}
      aria-invalid={!!error}
      aria-describedby={error ? `${groupId}-error` : undefined}
    >
      <legend>
        {legend}
        {required && <span aria-label="required">*</span>}
      </legend>

      {options.map((option) => {
        const optionId = `${groupId}-${option.value}`;
        return (
          <div key={option.value} className="radio-option">
            <input
              type="radio"
              id={optionId}
              value={option.value}
              {...register(name, {
                required: required && `${legend} is required`,
              })}
            />
            <label htmlFor={optionId}>
              {option.label}
              {option.description && (
                <span className="option-description">
                  {option.description}
                </span>
              )}
            </label>
          </div>
        );
      })}

      {error && (
        <p id={`${groupId}-error`} role="alert" className="error">
          {error.message}
        </p>
      )}
    </fieldset>
  );
}

// Usage
<AccessibleRadioGroup
  legend="Subscription Plan"
  name="plan"
  required
  options={[
    {
      value: 'free',
      label: 'Free',
      description: '$0/month - Basic features',
    },
    {
      value: 'pro',
      label: 'Pro',
      description: '$10/month - All features',
    },
  ]}
  register={register}
  errors={errors}
/>

Accessible Checkbox Group

function AccessibleCheckboxGroup({
  legend,
  name,
  options,
  required,
  register,
  errors,
}) {
  const groupId = useId();
  const error = errors[name];

  return (
    <fieldset
      className="form-field"
      aria-required={required}
      aria-invalid={!!error}
    >
      <legend>
        {legend}
        {required && <span aria-label="required">*</span>}
      </legend>

      {options.map((option) => {
        const optionId = `${groupId}-${option.value}`;
        return (
          <div key={option.value} className="checkbox-option">
            <input
              type="checkbox"
              id={optionId}
              value={option.value}
              {...register(name, {
                required: required && `Select at least one ${legend.toLowerCase()}`,
              })}
            />
            <label htmlFor={optionId}>{option.label}</label>
          </div>
        );
      })}

      {error && (
        <p role="alert" className="error">
          {error.message}
        </p>
      )}
    </fieldset>
  );
}

// Usage
<AccessibleCheckboxGroup
  legend="Interests"
  name="interests"
  required
  options={[
    { value: 'design', label: 'Design' },
    { value: 'development', label: 'Development' },
    { value: 'marketing', label: 'Marketing' },
  ]}
  register={register}
  errors={errors}
/>

Form-Level Error Summary

Display all errors at the top of the form for better accessibility:

function ErrorSummary({ errors }) {
  const errorEntries = Object.entries(errors);
  const errorSummaryRef = useRef(null);

  useEffect(() => {
    if (errorEntries.length > 0) {
      errorSummaryRef.current?.focus();
    }
  }, [errorEntries.length]);

  if (errorEntries.length === 0) return null;

  return (
    <div
      ref={errorSummaryRef}
      role="alert"
      aria-labelledby="error-summary-title"
      className="error-summary"
      tabIndex={-1}
    >
      <h3 id="error-summary-title">
        Please fix the following errors:
      </h3>
      <ul>
        {errorEntries.map(([field, error]) => (
          <li key={field}>
            <a href={`#${field}`}>
              {error.message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Usage in form
function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <ErrorSummary errors={errors} />
      {/* Form fields */}
    </form>
  );
}

Success Message After Submission

function Form() {
  const [submitSuccess, setSubmitSuccess] = useState(false);
  const successRef = useRef(null);

  const onSubmit = async (data) => {
    try {
      await submitForm(data);
      setSubmitSuccess(true);
      // Focus success message
      setTimeout(() => {
        successRef.current?.focus();
      }, 100);
    } catch (error) {
      // Handle error
    }
  };

  if (submitSuccess) {
    return (
      <div
        ref={successRef}
        role="alert"
        aria-live="polite"
        className="success-message"
        tabIndex={-1}
      >
        <h2>Success!</h2>
        <p>Your form has been submitted successfully.</p>
        <button onClick={() => setSubmitSuccess(false)}>
          Submit Another
        </button>
      </div>
    );
  }

  return <form>{/* Form */}</form>;
}

Accessible File Upload

function AccessibleFileUpload({ label, name, accept, register, errors }) {
  const [fileName, setFileName] = useState('');
  const id = useId();
  const error = errors[name];

  return (
    <div className="form-field">
      <label htmlFor={id}>{label}</label>

      <input
        type="file"
        id={id}
        accept={accept}
        aria-invalid={!!error}
        aria-describedby={
          fileName ? `${id}-status` : error ? `${id}-error` : undefined
        }
        {...register(name, {
          onChange: (e) => {
            const file = e.target.files[0];
            setFileName(file ? file.name : '');
          },
        })}
      />

      {fileName && (
        <p id={`${id}-status`} role="status" aria-live="polite">
          Selected file: {fileName}
        </p>
      )}

      {error && (
        <p id={`${id}-error`} role="alert" className="error">
          {error.message}
        </p>
      )}
    </div>
  );
}

Testing Your Accessible Forms

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('Registration Form', () => {
  test('shows validation errors', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm />);

    // Submit without filling
    const submitBtn = screen.getByRole('button', { name: /create account/i });
    await user.click(submitBtn);

    // Check for error messages
    expect(screen.getByRole('alert')).toHaveTextContent('Name is required');
  });

  test('form is keyboard accessible', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm />);

    // Tab through form
    await user.tab();
    expect(screen.getByLabelText(/full name/i)).toHaveFocus();

    await user.tab();
    expect(screen.getByLabelText(/email/i)).toHaveFocus();
  });

  test('labels are properly associated', () => {
    render(<RegistrationForm />);

    const emailInput = screen.getByLabelText(/email address/i);
    expect(emailInput).toBeInTheDocument();
    expect(emailInput).toHaveAttribute('type', 'email');
  });

  test('required fields are marked', () => {
    render(<RegistrationForm />);

    const nameInput = screen.getByLabelText(/full name/i);
    expect(nameInput).toHaveAttribute('aria-required', 'true');
  });
});

Common Form Accessibility Mistakes

❌ Using Placeholder as Label

// Bad
<input placeholder="Enter your email" />

// Good
<label htmlFor="email">Email</label>
<input id="email" placeholder="you@example.com" />

❌ Not Announcing Errors

// Bad
{error && <div className="error">{error}</div>}

// Good
{error && <div role="alert">{error}</div>}

❌ Poor Error Messages

// Bad
<p>Invalid input</p>

// Good
<p>Email must be in format: name@example.com</p>

❌ No Focus Management

// Bad - No focus after submission error
const onSubmit = async (data) => {
  try {
    await submit(data);
  } catch (error) {
    setError(error);
  }
};

// Good - Focus error summary
const onSubmit = async (data) => {
  try {
    await submit(data);
  } catch (error) {
    setError(error);
    errorSummaryRef.current?.focus();
  }
};

Best Practices Checklist

  • ✅ Every input has a visible label
  • ✅ Labels are properly associated with inputs
  • ✅ Required fields are marked with aria-required
  • ✅ Error messages use role="alert"
  • ✅ Errors are linked with aria-describedby
  • ✅ Invalid fields have aria-invalid="true"
  • ✅ Help text is associated with inputs
  • ✅ Use <fieldset> and <legend> for groups
  • ✅ Provide clear, specific error messages
  • ✅ Focus management on submit/error
  • ✅ Submit button has loading state
  • ✅ Use proper autocomplete attributes
  • ✅ Test with keyboard and screen reader

Useful AutoComplete Values

Field AutoComplete Value
Full Name name
Email email
Phone tel
Address street-address
City address-level2
Country country
Postal Code postal-code
New Password new-password
Current Password current-password
Birthday bday

Resources for Further Learning

  • React Hook Form Docs - Official documentation
  • WCAG Form Guidelines - Accessibility standards
  • WebAIM Form Accessibility - Best practices guide
  • GOV.UK Design System - Excellent form patterns
  • a11y Project - Accessibility checklist

Key Takeaways

  • React Hook Form handles validation, you handle accessibility
  • Every input needs a proper label and error handling
  • Use aria-required, aria-invalid, and aria-describedby
  • Error messages must have role="alert" for immediate announcement
  • Group related fields with <fieldset> and <legend>
  • Provide clear, actionable error messages
  • Test with keyboard navigation and screen readers
  • Focus management is crucial for good UX

Building accessible forms with React Hook Form isn't hard—it just requires attention to detail. Use proper labels, manage focus, announce errors clearly, and always test with keyboard and screen readers. Your users will appreciate the effort, and you'll avoid expensive accessibility lawsuits. Win-win!

💡 Pro tip: Create reusable accessible form components in your project. Once you've built them correctly once, you can use them everywhere and maintain consistency across your app!