We've reviewed a lot of codebases. Forms are consistently the most neglected part of accessibility. Not because developers are lazy — it's because accessible forms require a bunch of small things all working together: correct ARIA attributes, live error announcements, proper label associations, focus management. Miss one and a screen reader user is stuck on your signup page forever.
The good news: React Hook Form and Zod, when set up correctly, get you 80% of the way there. The remaining 20% is just knowing what to wire up. This post covers both.
Why Most Form Implementations Fail Accessibility
Here's the typical pattern we see. Developer installs React Hook Form, adds Zod validation, renders errors below inputs. Looks fine visually. Fails completely with a screen reader.
The three most common failures: error messages aren't associated with their inputs (missing aria-describedby), invalid state isn't communicated (missing aria-invalid), and error messages appear in the DOM but aren't announced to screen readers (missing a live region). You can fix all three without much extra code.
The Base Setup: Zod Schema + RHF
Start with a solid Zod schema. The error messages you define here are what users — all users — will see. Write them like a human, not a validator.
import { z } from 'zod'
export const signupSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
export type SignupFormValues = z.infer<typeof signupSchema>Notice the error messages. "Email is required" is better than "Invalid string". "Password must contain at least one uppercase letter" tells you exactly what to fix. Don't be clever with validation messages — be specific.
Building the Accessible Field Component
Rather than copy-pasting ARIA attributes across every input, build one FormField component that handles it all. This is the component we use in basically every project.
import { useId } from 'react'
import type { FieldError } from 'react-hook-form'
interface FormFieldProps {
label: string
error?: FieldError
hint?: string
required?: boolean
children: (props: {
id: string
'aria-describedby'?: string
'aria-invalid': boolean
'aria-required': boolean
}) => React.ReactNode
}
export function FormField({
label,
error,
hint,
required = false,
children,
}: FormFieldProps) {
const id = useId()
const errorId = `${id}-error`
const hintId = `${id}-hint`
const describedBy = [
hint ? hintId : null,
error ? errorId : null,
]
.filter(Boolean)
.join(' ') || undefined
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm font-medium">
{label}
{required && (
<span aria-hidden="true" className="ml-1 text-red-500">
*
</span>
)}
</label>
{hint && (
<p id={hintId} className="text-sm text-gray-500">
{hint}
</p>
)}
{children({
id,
'aria-describedby': describedBy,
'aria-invalid': !!error,
'aria-required': required,
})}
{error && (
<p
id={errorId}
role="alert"
className="text-sm text-red-600"
>
{error.message}
</p>
)}
</div>
)
}A few things worth explaining here. We use React's useId() hook to generate stable IDs — critical for SSR where random IDs would cause hydration mismatches. The aria-describedby gets both the hint and error IDs when both exist, separated by a space. The role="alert" on the error paragraph means it gets announced immediately when it appears in the DOM, without you needing a separate live region.
role="alert" is an implicit aria-live="assertive" region. It interrupts the screen reader to announce the content. Use it for errors. Don't use it for success messages — those should be polite, not assertive.
Wiring It All Together
Now the form itself. Using the zodResolver from @hookform/resolvers makes Zod integration seamless. Here's the full signup form using our FormField component.
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { signupSchema, type SignupFormValues } from './schema'
import { FormField } from './FormField'
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
mode: 'onBlur', // validate on blur, not on every keystroke
})
const onSubmit = async (data: SignupFormValues) => {
// your submit logic here
await new Promise((r) => setTimeout(r, 1000))
console.log(data)
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate // disable native browser validation, we handle it
aria-label="Create your account"
>
<FormField
label="Email address"
error={errors.email}
required
>
{(fieldProps) => (
<input
type="email"
autoComplete="email"
className="rounded border px-3 py-2 aria-invalid:border-red-500"
{...fieldProps}
{...register('email')}
/>
)}
</FormField>
<FormField
label="Password"
error={errors.password}
hint="At least 8 characters, one uppercase letter and one number"
required
>
{(fieldProps) => (
<input
type="password"
autoComplete="new-password"
className="rounded border px-3 py-2 aria-invalid:border-red-500"
{...fieldProps}
{...register('password')}
/>
)}
</FormField>
<FormField
label="Confirm password"
error={errors.confirmPassword}
required
>
{(fieldProps) => (
<input
type="password"
autoComplete="new-password"
className="rounded border px-3 py-2 aria-invalid:border-red-500"
{...fieldProps}
{...register('confirmPassword')}
/>
)}
</FormField>
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</form>
)
}Notice noValidate on the form. This disables native browser validation (which has inconsistent accessibility support across browsers) in favor of your custom validation. Also note aria-busy on the submit button — screen readers announce this state, so users know something is happening after they submit.
The mode: 'onBlur' is worth discussing. Validating on every keystroke (onChange) is annoying for keyboard users and screen reader users alike. Validating on blur is a reasonable default: you get feedback after leaving a field, not while you're still typing. For confirmPassword specifically, you might want 'onSubmit' mode since the cross-field validation is confusing mid-input.
The Styling Hook: aria-invalid in Tailwind
One thing we love about Tailwind is the aria-invalid: variant. Instead of conditionally adding error classes with JavaScript, you can style directly based on the ARIA state:
// Instead of this:
className={`border ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
// Do this:
className="border border-gray-300 aria-invalid:border-red-500 aria-invalid:ring-red-500/20"
// Or with a focus ring:
className="
rounded border border-gray-300 px-3 py-2
focus:outline-none focus:ring-2 focus:ring-blue-500
aria-invalid:border-red-500
aria-invalid:focus:ring-red-500
"This is great because your styling is now driven by semantic state rather than a parallel JavaScript variable. The visual error state and the ARIA error state can't get out of sync — they're literally the same thing.
Handling Form-Level Errors
Sometimes validation happens server-side — duplicate email, wrong current password, rate limiting. These errors don't belong to a specific field. You need a way to show them accessibly.
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
export function SignupForm() {
const [formError, setFormError] = useState<string | null>(null)
const { handleSubmit, ...rest } = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
})
const onSubmit = async (data: SignupFormValues) => {
setFormError(null)
try {
const res = await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
})
if (!res.ok) {
const { message } = await res.json()
setFormError(message ?? 'Something went wrong. Please try again.')
return
}
// success: redirect or update UI
} catch {
setFormError('Network error. Check your connection and try again.')
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Form-level error — role="alert" announces it immediately */}
{formError && (
<div
role="alert"
className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
>
{formError}
</div>
)}
{/* rest of your fields */}
</form>
)
}The form-level error using role="alert" gets announced by screen readers the moment it appears. We also reset it to null at the start of each submit attempt, which forces a re-render and re-announcement even if the error message is identical to the previous one.
The Checklist You Actually Need
Before shipping any form, run through this list. It takes five minutes and catches most issues.
- Every input has a visible <label> associated via htmlFor/id (not just placeholder text)
- Required fields communicate that requirement both visually and with aria-required
- Error messages are linked to their inputs via aria-describedby
- Inputs have aria-invalid="true" when they have errors
- Error messages use role="alert" so they're announced when they appear
- Hint text (format requirements, etc.) is also in aria-describedby, before the error
- Submit button has aria-busy during submission
- form has noValidate if you're handling validation yourself
- Tab order is logical — follows visual order, no surprise focus jumps
- Color is not the only way to communicate errors (add an icon or text, not just red border)
You can test most of this yourself. Turn on VoiceOver (Mac: Cmd+F5) or NVDA (Windows, free), tab through your form, and submit with errors. If you can understand what went wrong and fix it without seeing the screen, you're in good shape. We learned to do this after a user emailed us about a broken checkout form — embarrassing, fixable, now standard practice.
Accessibility testing with real assistive tech takes 10 minutes and catches more than automated tools. axe-core and similar scanners catch maybe 30-40% of real issues. Tab through your own forms.
One More Thing: Focus Management on Submit
When a form has validation errors on submit, focus should move to the first error or a summary. React Hook Form's setFocus from the form instance handles this nicely.
const {
register,
handleSubmit,
setFocus,
formState: { errors },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
})
// After a failed submit attempt, focus the first field with an error
const onInvalid = () => {
const firstErrorField = Object.keys(errors)[0] as keyof SignupFormValues
if (firstErrorField) {
setFocus(firstErrorField)
}
}
return (
<form onSubmit={handleSubmit(onSubmit, onInvalid)} noValidate>
{/* ... */}
</form>
)handleSubmit accepts a second argument: the onInvalid callback, called when validation fails on submit. Moving focus to the first error means keyboard users don't have to go hunting for what went wrong. Small thing, big difference.
If you're starting a new project and want a baseline that has all of this already wired up — including auth forms, payment forms, and settings forms built with these patterns — the templates on peal.dev include accessible form components out of the box. Saves you the "oh I forgot aria-describedby again" moment at 2am before a launch.
Accessible forms aren't a separate thing from good forms. Error messages that are clear, inputs that communicate their state, labels that are always present — these help everyone, not just screen reader users. The ARIA attributes are just making sure the browser can communicate that information to assistive tech the same way it communicates it visually. Once you have a solid FormField component like the one above, you stop thinking about it. It's just how your forms work.
