Introduction
Forms are where accessibility failures cause the most real-world harm. If a user can't complete a checkout form, sign up for a service, or submit a complaint, they're excluded from participating. Accessible forms aren't just good practice — they directly impact whether people can use your product.
The good news: most form accessibility comes from proper HTML. Labels, fieldsets, error messages, and input types. ARIA is only needed for custom widgets that go beyond native form controls.
This guide covers labeling, error handling, fieldset grouping, validation feedback, and complex form patterns like multi-step wizards.
Key Concepts
Proper Labeling
// ✅ Explicit label with htmlFor
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// ✅ Help text with aria-describedby
<label htmlFor="password">Password</label>
<input id="password" type="password" aria-describedby="password-help" />
<p id="password-help" className="text-sm text-gray-500">
At least 8 characters with one number
</p>
// ❌ Placeholder is NOT a label
<input placeholder="Enter email" /> // Label disappears on input!
// ✅ Visually hidden label (when design has no visible label)
<label htmlFor="search" className="sr-only">Search</label>
<input id="search" type="search" placeholder="Search..." />
Error Handling
// ✅ Accessible error messages
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined} />
{error && (
<p id="email-error" role="alert" className="text-red-600 text-sm mt-1">
⚠️ {error}
</p>
)}
</div>
// ✅ Error summary at top of form
{errors.length > 0 && (
<div role="alert" className="bg-red-50 border border-red-200 p-4 rounded mb-4">
<h2 className="font-bold text-red-800">Please fix the following errors:</h2>
<ul className="list-disc pl-5 mt-2">
{errors.map(err => (
<li key={err.field}>
<a href={`#${err.field}`} className="text-red-700 underline">{err.message}</a>
</li>
))}
</ul>
</div>
)}
Fieldset and Legend
// Group related fields with fieldset + legend
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" />
<label htmlFor="city">City</label>
<input id="city" />
</fieldset>
// Essential for radio/checkbox groups
<fieldset>
<legend>Payment Method</legend>
<label><input type="radio" name="payment" value="card" /> Credit Card</label>
<label><input type="radio" name="payment" value="paypal" /> PayPal</label>
<label><input type="radio" name="payment" value="bank" /> Bank Transfer</label>
</fieldset>
Practical Examples
1. Complete Accessible Form
export function SignupForm() {
const [state, action, pending] = useActionState(signup, null);
return (
<form action={action} noValidate>
{state?.errors?.form && (
<div role="alert" className="mb-4 p-3 bg-red-50 text-red-800 rounded">
{state.errors.form}
</div>
)}
<div className="mb-4">
<label htmlFor="name" className="block font-medium">
Full Name <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</span>
</label>
<input id="name" name="name" required aria-invalid={!!state?.errors?.name}
aria-describedby={state?.errors?.name ? 'name-error' : undefined}
className="w-full border rounded px-3 py-2" />
{state?.errors?.name && (
<p id="name-error" className="text-red-600 text-sm mt-1">{state.errors.name}</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Creating account...' : 'Create Account'}
</button>
</form>
);
}
2. Custom Checkbox
function Checkbox({ id, label, checked, onChange }: CheckboxProps) {
return (
<label htmlFor={id} className="flex items-center gap-2 cursor-pointer">
<input id={id} type="checkbox" checked={checked} onChange={onChange}
className="sr-only peer" />
<div className="w-5 h-5 border-2 rounded peer-checked:bg-blue-600 peer-checked:border-blue-600
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2">
{checked && <CheckIcon className="text-white" />}
</div>
<span>{label}</span>
</label>
);
}
3. Autocomplete/Combobox
function Combobox({ label, options, value, onChange }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const filtered = options.filter(o => o.toLowerCase().includes(query.toLowerCase()));
return (
<div>
<label htmlFor="combo">{label}</label>
<input id="combo" role="combobox" aria-expanded={open} aria-autocomplete="list"
aria-controls="combo-list" value={query}
onChange={e => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)} />
{open && (
<ul id="combo-list" role="listbox">
{filtered.map(opt => (
<li key={opt} role="option" aria-selected={opt === value}
onClick={() => { onChange(opt); setQuery(opt); setOpen(false); }}>
{opt}
</li>
))}
</ul>
)}
</div>
);
}
Best Practices
- ✅ Every input needs a label — visible or visually hidden, never just placeholder
- ✅ Use aria-invalid and aria-describedby for error messages
- ✅ Group related fields with fieldset and legend
- ✅ Provide an error summary with links to each invalid field
- ✅ Mark required fields with both visual indicator and aria-required
- ❌ Don't use placeholder as a label — it disappears when typing
- ❌ Don't validate on blur for every field — it's disorienting for screen reader users
Common Pitfalls
- Custom styled inputs that lose native keyboard and screen reader support
- Error messages not associated with inputs — screen readers can't find them
- Radio buttons without a fieldset/legend — screen readers don't announce the group question
- Required indicators (asterisks) without explaining what they mean
Related Guides
- React Form Handling — React patterns for form state and validation
- ARIA Attributes Guide — ARIA for custom form controls
- Keyboard Accessibility Guide — Keyboard navigation in forms
- Screen Reader Testing — Testing forms with assistive technology