Home Frontend

How to Build Accessible Forms in React with React Hook Form and Zod

Frontend

September 12, 2026

How to Build Accessible Forms in React with React Hook Form and Zod

Forms are where accessibility quietly falls apart. A label gets dropped during a refactor, an error appears in red with nothing tying it to the input, and a screen reader user is left guessing what went wrong. React Hook Form and Zod solve the state and validation problems neatly, but neither library hands you a compliant form. The markup, the messaging and the focus behaviour are still yours to get right.

Start with native HTML, then layer the library on top

React Hook Form's register function spreads props onto a real input element, so the semantics come from the markup you write. Settle that first, before you think about validation.

  • Give every input a label element with a matching htmlFor and id. Placeholders disappear as soon as someone types and often fail contrast requirements; they are not labels.
  • Use the right type: email, tel, password, number. That improves mobile keyboards and lets the browser help with validation.
  • Add autocomplete tokens such as given-name, email or postal-code. Autofill is an accessibility feature for people with motor or memory impairments, not just a convenience.
  • Group related controls in a fieldset with a legend: radio sets, address blocks, date parts.
  • Never disable the submit button to signal a problem. Disabled controls drop out of the tab order and explain nothing.

If a form only works because a library attached behaviour to it, you have made your own job harder than it needed to be.

Define the rules once with Zod

A Zod schema keeps validation in one place and hands you a TypeScript type at the same time. Define email as a string with a minimum length and an email check, and pass a message to each rule that tells the reader what to do. "Enter an email address in the format [email protected]" beats "Invalid input" every time.

Once the schema exists, zodResolver from @hookform/resolvers/zod feeds it into useForm's resolver option, and z.infer gives you a FormValues type to pass back to useForm generically. If your server can import the same schema, client and server rules stop drifting apart.

Error messages are interface copy

Write them in the same voice as the rest of the page, keep them specific, and avoid jargon such as "validation failed". Say what is wrong and what to do about it. Screen reader users hear these messages out of context, so each one should make sense on its own.

Pick a validation mode that does not interrupt

The mode option matters more for accessibility than most tutorials admit. With mode set to onChange, errors appear and are announced while somebody is still typing the first few characters of their email address. For anyone using a screen reader or voice control, that is noise that makes the field harder to finish.

Setting mode to 'onTouched' or 'onBlur' is gentler: the first message arrives when the person leaves the field. Pair it with reValidateMode: 'onChange' so that once an error has been flagged, it clears the moment it is fixed. Feedback arrives exactly when it is useful, and stays quiet the rest of the time.

Connect every error to the field it belongs to

This is where most forms fail an audit. An error rendered underneath an input but not programmatically tied to it does not exist for a screen reader.

  • Set aria-invalid on the input while it has an error.
  • Give the error paragraph an id and list that id in the input's aria-describedby, alongside any hint text.
  • Add role="alert" to field-level errors so they are announced when they appear.
  • Keep the message in text, not colour alone. A red border vanishes for anyone with a colour vision deficiency.
  • Mark required fields with the required attribute or aria-required, and explain the convention in words rather than relying on an asterisk.

A single Field component that takes an id, label, hint and error will save you from repeating all of this. Build it before your third form, not after it.

Move focus deliberately after a failed submit

React Hook Form focuses the first invalid field by default, which is a reasonable starting point. It is not enough on its own: the person knows one field is wrong, not how many others remain. An error summary at the top of the form, following the pattern used across government services, closes that gap.

Render a heading such as "There is a problem", then a list of the errors, each item linking to the relevant field. Give the container a tabIndex of -1 and focus it from handleSubmit's second callback, which runs when validation fails. Activating a link in the summary should move focus to the input itself, not merely scroll it into view.

Keep the summary out of the DOM until a submit attempt has happened. An empty alert region on page load is confusing, and a list that rewrites itself on every keystroke is worse.

Groups, radios and checkboxes need extra care

A radio set is one question with several answers, so wrap it in a fieldset with a legend and attach the error to the group rather than to each individual radio. Register every input, but describe the group. The same applies to a date split across three fields and to a set of address lines.

For a single checkbox confirming terms, the error belongs to that checkbox and must be reachable with the keyboard. Keep the label properly associated with its input, which also gives you a larger click target for free.

Test accessibility the way you test logic

  • Run axe inside your test suite with jest-axe, or @axe-core/react during development.
  • Tab through the entire form without a mouse and check that everything is reachable and completable.
  • Trigger every error and confirm each one is announced and tied to the correct field.
  • Check the layout at 200 per cent zoom and at 400 per cent width; nothing should be clipped or need horizontal scrolling.
  • Try browser autofill, which can skip change handlers and occasionally exposes stale error state.

A ten-minute pass with VoiceOver on macOS or NVDA on Windows will find problems no linter catches.

A form that behaves well for everyone

Native semantics first, a Zod schema for the rules, a validation mode that stays quiet until it is useful, and error messages that are announced and linked to their fields. None of this is exotic; it is detail work that pays off. Write one Field wrapper and one error summary, reuse them everywhere, and add an axe check to your pipeline so the next refactor cannot quietly undo it.

Photo: schauhi / Pixabay

Related Posts

Developer Laptop Setup Checklist for New UK Hires
Tools

October 10, 2026

Developer Laptop Setup Checklist for New UK Hires

A practical checklist for setting up a secure, comfortable development laptop as a new UK hire, from disk encryption and access requests...

read more
A Beginner's Guide to Database Normalisation for Small Business Apps
Databases

October 09, 2026

A Beginner's Guide to Database Normalisation for Small Business Apps

A practical introduction to first, second and third normal forms, with clear examples showing how to structure small business data...

read more
How to Run Zero-Downtime Database Migrations
Databases

October 07, 2026

How to Run Zero-Downtime Database Migrations

Practical steps for changing production schemas without downtime: the expand-and-contract pattern, lock-aware statements, deploy...

read more