How to build an accessible form with react-hook-form and Zod
A form that only looks correct isn’t done. If a screen-reader user can’t tell which field failed, or can’t hear that their message actually sent, the form is broken for them. This post walks through the contact form on this site and shows exactly which attributes make it accessible — none of them are complicated.
The setup: react-hook-form + Zod
react-hook-form manages form state (values, errors, submission) with very few
re-renders. I pair it with Zod for validation through the zodResolver adapter —
and that same schema is reused on the server, as covered in sharing one Zod schema between client and server:
// src/components/portfolio/ContactForm.tsx
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onBlur",
})register("name")connects an input to the form; you spread it onto the element with{...register("name")}.handleSubmit(onSubmit)runs validation first and only callsonSubmitif the data is valid.errorsis an object keyed by field name;errors.nameexists only when the name field failed.mode: "onBlur"validates a field when the user leaves it, so errors appear as they move through the form rather than only on submit.
A status state machine
Instead of juggling loose booleans like isLoading and isDone, I track one
value with four possible states:
type Status = "idle" | "submitting" | "success" | "error"
const [status, setStatus] = useState<Status>("idle")
const [serverError, setServerError] = useState<string | null>(null)Only one state can be true at a time, so the UI can’t get into an impossible combination like “loading and success at once.” The submit handler moves through the states:
async function onSubmit(values: ContactInput) {
setStatus("submitting")
setServerError(null)
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
const data = await res.json().catch(() => ({}))
if (!res.ok || !data.ok) throw new Error(data.error || "Failed to send message.")
setStatus("success")
reset()
} catch (err) {
setStatus("error")
setServerError(err instanceof Error ? err.message : "Failed to send message.")
}
}Note reset() on success — it clears the fields so the form is ready for the
next message.
Making a single field accessible
Here’s the name field. Accessibility lives in three attributes:
<label htmlFor="cf-name">name</label>
<input
id="cf-name"
type="text"
autoComplete="name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "cf-name-err" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="cf-name-err" role="alert">
{errors.name.message}
</p>
)}What each piece does:
htmlFor/idlink the label to the input. Clicking the label focuses the field, and screen readers announce the label when the field is focused.aria-invalidistrueonly when this field has an error, so assistive tech announces the field as invalid.!!errors.nameturns the error object (orundefined) into a boolean.aria-describedbypoints the input at the id of its error message, so the error text is read out as part of the field’s description. I only set it when there’s actually an error — otherwise it’sundefined(unset).role="alert"on the error paragraph makes screen readers announce the message the moment it appears.
The same pattern repeats for the email and message fields — only the ids change.
Announcing the submit outcome
Field errors are covered, but what about “message sent” or “the server failed”? Those appear after submit, so I put them in a live region:
<div aria-live="polite">
{status === "error" && serverError && (
<p role="alert">{serverError}</p>
)}
</div>aria-live="polite" tells the screen reader to announce whatever appears inside
this container, without interrupting the user mid-sentence. The success state
uses role="status" on its container for the same reason. Sighted users get a
green checkmark and a message; screen-reader users hear it too.
What to remember
- Let
zodResolverconnect react-hook-form to your Zod schema;mode: "onBlur"gives friendly, early validation. - Model submission as one
statusvalue, not several loose booleans. - Each field needs a linked
label,aria-invalid, andaria-describedbypointing at its error, plusrole="alert"on the error text. - Wrap after-submit feedback in an
aria-live="polite"region so it’s announced. - Call
reset()after a successful submit to clear the form.
Related reading: the same accessibility mindset applies to page navigation — see adding an accessible skip link and ARIA-correct navigation. On the backend side, the /api/contact endpoint this form posts to is built in sending email from a serverless function with Resend, and both layers are covered in unit-testing a form and serverless handler with Vitest.