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:
htmlForconnects label to inputuseId()generates unique IDsaria-requiredannounces required fieldsaria-invalidindicates validation errorsaria-describedbylinks help text and errorsrole="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
autocompleteattributes - ✅ Test with keyboard and screen reader
Useful AutoComplete Values
| Field | AutoComplete Value |
|---|---|
| Full Name | name |
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, andaria-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!