Building Accessible React Forms Without a Library
By Wilson Xu — 2,700 words
Every React form library — React Hook Form, Formik, Zod-validated controllers — handles validation logic. None of them guarantee accessibility. You can have perfectly validated, zero-dependency forms that are completely unusable by screen reader users, keyboard-only navigators, and people with motor disabilities.
This article is about building forms that work for everyone, from scratch, without pulling in @radix-ui/react-form or downshift. We'll cover ARIA attributes, focus management, error announcements, keyboard navigation, and the patterns that make the difference between "it works" and "it works for all users."
Why Library Forms Aren't Enough
React Hook Form handles registration, validation, and submission. It does not:
- Announce validation errors to screen readers
- Move focus to the first error after submission
- Associate error messages with their inputs via
aria-describedby - Manage
aria-invalidstates correctly - Handle dynamic field groups (add/remove rows) accessibly
You have to do all of that yourself. The good news: it's not that much code.
The Accessible Form Foundation
Every form field needs four things:
- A
<label>with afor/htmlForpointing to the input'sid - An error message element with a stable
id -
aria-describedbyon the input pointing to the error element'sid -
aria-invalid="true"when the field has an error
// components/FormField.tsx
interface FormFieldProps {
id: string
label: string
error?: string
required?: boolean
children: (props: {
id: string
'aria-describedby': string
'aria-invalid': boolean
'aria-required': boolean
}) => React.ReactNode
}
export function FormField({ id, label, error, required, children }: FormFieldProps) {
const errorId = `${id}-error`
const descriptionId = `${id}-description`
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && (
<span aria-hidden="true" className="required-indicator"> *</span>
)}
</label>
{children({
id,
'aria-describedby': error ? errorId : descriptionId,
'aria-invalid': !!error,
'aria-required': !!required,
})}
{/* Error message: always in DOM, visible only when needed */}
<span
id={errorId}
role="alert"
aria-live="polite"
className={`form-error ${error ? 'visible' : 'hidden'}`}
>
{error ?? ''}
</span>
</div>
)
}
Usage:
<FormField id="email" label="Email address" error={errors.email} required>
{(fieldProps) => (
<input
type="email"
{...fieldProps}
value={email}
onChange={e => setEmail(e.target.value)}
/>
)}
</FormField>
The render prop pattern lets FormField own the ARIA wiring while the caller controls the input type.
Error Announcements: Getting Screen Readers to Actually Speak
role="alert" announces content changes to screen readers automatically — but only when the content changes. If you toggle visibility with CSS while leaving the text in the DOM, some screen readers won't announce it. The safest approach:
// Always render the error container. Change its text content, not its visibility.
// role="alert" + aria-live="polite" = announced immediately but not interruptive
<span
id={`${id}-error`}
role="alert"
aria-live="polite"
>
{error} {/* When this text appears/changes, screen readers announce it */}
</span>
For more urgent errors (e.g., security alerts), use aria-live="assertive" — it interrupts the current speech. For form validation, polite is almost always correct.
The Hidden Trap: Multiple Errors on Submit
When a user submits an invalid form, you might set 5 errors at once. With aria-live regions on each field, the screen reader gets 5 simultaneous announcements and reads only one (or none).
The fix: a single form-level live region that summarizes all errors.
// components/ErrorSummary.tsx
interface ErrorSummaryProps {
errors: Record<string, string>
headingRef?: React.RefObject<HTMLHeadingElement>
}
export function ErrorSummary({ errors, headingRef }: ErrorSummaryProps) {
const errorEntries = Object.entries(errors).filter(([, msg]) => msg)
if (errorEntries.length === 0) return null
return (
<div
role="alert"
aria-labelledby="error-summary-heading"
className="error-summary"
>
<h2 id="error-summary-heading" ref={headingRef} tabIndex={-1}>
{errorEntries.length === 1
? 'There is 1 error in this form'
: `There are ${errorEntries.length} errors in this form`}
</h2>
<ul>
{errorEntries.map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
)
}
Each error in the summary links to the relevant field. When the user clicks a link, focus moves directly to the problematic input.
Focus Management: Where Focus Goes Matters
After form submission with errors, sighted users see the error highlights. Screen reader users need focus to move to the right place.
// hooks/useFormSubmit.ts
import { useRef, useCallback } from 'react'
export function useFormSubmit<T extends Record<string, string>>(
validate: (values: Record<string, string>) => T,
onSubmit: (values: Record<string, string>) => Promise<void>
) {
const errorSummaryRef = useRef<HTMLHeadingElement>(null)
const [errors, setErrors] = useState<T>({} as T)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = useCallback(
async (e: React.FormEvent, values: Record<string, string>) => {
e.preventDefault()
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
// Move focus to error summary after state update
requestAnimationFrame(() => {
errorSummaryRef.current?.focus()
})
return
}
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
},
[validate, onSubmit]
)
return { errors, isSubmitting, handleSubmit, errorSummaryRef }
}
requestAnimationFrame ensures React has committed the error summary to the DOM before we try to focus it. This is the correct pattern — useEffect with a dependency on errors also works but is slightly less predictable.
Focus Trapping in Modals and Dialogs
If your form is inside a modal, you must trap focus within it. Clicking outside should not allow Tab to escape to the document behind.
// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react'
const FOCUSABLE_SELECTORS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
export function useFocusTrap(active: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!active) return
const container = containerRef.current
if (!container) return
// Focus first focusable element
const focusables = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
focusables[0]?.focus()
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
const focusable = Array.from(
container!.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
).filter(el => !el.closest('[aria-hidden="true"]'))
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [active])
return containerRef
}
Usage:
function FormModal({ isOpen, onClose }) {
const trapRef = useFocusTrap(isOpen)
return isOpen ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
ref={trapRef}
>
<h2 id="dialog-title">Edit Profile</h2>
<ProfileForm />
<button onClick={onClose}>Cancel</button>
</div>
) : null
}
Keyboard Navigation: Beyond Tab Order
Tab order follows DOM order. If your visual layout reorders elements with CSS flexbox or grid, your tab order may be confusing. Never use tabindex values greater than 0 — they break natural tab order globally.
Composite Widgets: Radio Groups and Select Lists
For custom radio groups, manage focus within the group with arrow keys — this is the expected keyboard pattern per ARIA Authoring Practices.
// components/RadioGroup.tsx
'use client'
import { useRef, KeyboardEvent } from 'react'
interface RadioOption {
value: string
label: string
}
interface RadioGroupProps {
name: string
legend: string
options: RadioOption[]
value: string
onChange: (value: string) => void
error?: string
}
export function RadioGroup({ name, legend, options, value, onChange, error }: RadioGroupProps) {
const groupRef = useRef<HTMLFieldSetElement>(null)
const errorId = `${name}-error`
function handleKeyDown(e: KeyboardEvent, index: number) {
const total = options.length
let nextIndex = index
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault()
nextIndex = (index + 1) % total
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault()
nextIndex = (index - 1 + total) % total
} else {
return
}
const radios = groupRef.current?.querySelectorAll<HTMLInputElement>('input[type="radio"]')
radios?.[nextIndex]?.focus()
onChange(options[nextIndex].value)
}
return (
<fieldset ref={groupRef} aria-describedby={error ? errorId : undefined}>
<legend>{legend}</legend>
{options.map((option, index) => (
<label key={option.value} className="radio-label">
<input
type="radio"
name={name}
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
tabIndex={value === option.value ? 0 : -1}
aria-invalid={!!error}
/>
{option.label}
</label>
))}
{error && (
<span id={errorId} role="alert" className="form-error">
{error}
</span>
)}
</fieldset>
)
}
The tabIndex={value === option.value ? 0 : -1} pattern is "roving tabindex" — only the selected radio is in the tab sequence. Arrow keys move between options.
Dynamic Fields: Accessible Add/Remove Patterns
Forms with "Add another" rows are notoriously tricky.
// components/DynamicList.tsx
'use client'
import { useRef } from 'react'
export function DynamicEmailList() {
const [emails, setEmails] = useState([''])
const addButtonRef = useRef<HTMLButtonElement>(null)
const lastInputRef = useRef<HTMLInputElement>(null)
function addEmail() {
setEmails(prev => [...prev, ''])
// Focus the new input after render
requestAnimationFrame(() => {
lastInputRef.current?.focus()
})
}
function removeEmail(index: number) {
setEmails(prev => prev.filter((_, i) => i !== index))
// Return focus to "Add" button when last item removed, or previous item
requestAnimationFrame(() => {
addButtonRef.current?.focus()
})
}
return (
<fieldset>
<legend>Email addresses</legend>
{emails.map((email, index) => (
<div key={index} className="dynamic-row">
<label htmlFor={`email-${index}`}>
Email {index + 1}
</label>
<input
id={`email-${index}`}
type="email"
value={email}
ref={index === emails.length - 1 ? lastInputRef : undefined}
onChange={e => {
const next = [...emails]
next[index] = e.target.value
setEmails(next)
}}
aria-label={`Email address ${index + 1} of ${emails.length}`}
/>
{emails.length > 1 && (
<button
type="button"
onClick={() => removeEmail(index)}
aria-label={`Remove email ${index + 1}: ${email || 'empty'}`}
>
Remove
</button>
)}
</div>
))}
<button
type="button"
ref={addButtonRef}
onClick={addEmail}
aria-label={`Add another email address (${emails.length} currently)`}
>
+ Add email
</button>
</fieldset>
)
}
The aria-label on the remove button is critical. "Remove" alone is ambiguous — "Remove email 2: user@example.com" is unambiguous.
Putting It All Together: A Complete Accessible Form
// components/ContactForm.tsx
'use client'
import { useState, useRef } from 'react'
import { FormField } from './FormField'
import { ErrorSummary } from './ErrorSummary'
interface FormValues {
name: string
email: string
subject: string
message: string
}
interface FormErrors {
name?: string
email?: string
subject?: string
message?: string
}
function validate(values: FormValues): FormErrors {
const errors: FormErrors = {}
if (!values.name.trim()) errors.name = 'Name is required'
if (!values.email.trim()) errors.email = 'Email is required'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email))
errors.email = 'Enter a valid email address'
if (!values.subject) errors.subject = 'Please select a subject'
if (values.message.trim().length < 10)
errors.message = 'Message must be at least 10 characters'
return errors
}
export function ContactForm() {
const [values, setValues] = useState<FormValues>({
name: '', email: '', subject: '', message: '',
})
const [errors, setErrors] = useState<FormErrors>({})
const [submitted, setSubmitted] = useState(false)
const errorSummaryRef = useRef<HTMLHeadingElement>(null)
function update(field: keyof FormValues) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setValues(prev => ({ ...prev, [field]: e.target.value }))
// Clear error on change
if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
requestAnimationFrame(() => errorSummaryRef.current?.focus())
return
}
// Submit...
setSubmitted(true)
}
if (submitted) {
return (
<div role="status" aria-live="polite">
<h2>Message sent successfully!</h2>
<p>We'll be in touch within 2 business days.</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} noValidate aria-label="Contact form">
<ErrorSummary errors={errors} headingRef={errorSummaryRef} />
<FormField id="name" label="Full name" error={errors.name} required>
{(p) => <input type="text" {...p} value={values.name} onChange={update('name')} autoComplete="name" />}
</FormField>
<FormField id="email" label="Email address" error={errors.email} required>
{(p) => <input type="email" {...p} value={values.email} onChange={update('email')} autoComplete="email" />}
</FormField>
<FormField id="subject" label="Subject" error={errors.subject} required>
{(p) => (
<select {...p} value={values.subject} onChange={update('subject')}>
<option value="">Select a subject</option>
<option value="general">General enquiry</option>
<option value="support">Technical support</option>
<option value="billing">Billing</option>
</select>
)}
</FormField>
<FormField id="message" label="Message" error={errors.message} required>
{(p) => (
<textarea
{...p}
value={values.message}
onChange={update('message')}
rows={5}
aria-describedby={`${p['aria-describedby']} message-hint`}
/>
)}
</FormField>
<span id="message-hint" className="field-hint">Minimum 10 characters</span>
<button type="submit">Send message</button>
</form>
)
}
Checklist Before Shipping
- [ ] Every input has a visible, programmatically associated
<label> - [ ] Errors are announced via
aria-liveregions orrole="alert" - [ ] Error summary with focus management on failed submission
- [ ]
aria-invalid="true"set on invalid fields - [ ]
aria-describedbylinks inputs to their error messages - [ ] Custom widgets (radio groups, dropdowns) implement keyboard patterns from ARIA APG
- [ ] Dynamic add/remove fields move focus sensibly
- [ ] Form works completely with keyboard alone (no mouse required)
- [ ] Tested with VoiceOver (macOS/iOS) and NVDA (Windows)
- [ ]
noValidateon<form>— use your own validation, not browser bubbles
Accessibility isn't a feature you add at the end. These patterns take minutes to implement when you build them in from the start. Your users who depend on them don't get a "we'll add that later" — they just can't use your product.
Wilson Xu writes about frontend engineering and accessibility. He contributes to open-source tooling at github.com/chengyixu.
Top comments (0)