React Form Components: Accessible Fields, Zod and Mobile
Every product ships the same handful of forms: sign up, checkout, settings, a filter panel, a support request. The controls differ, the bugs repeat. A label that is not tied to its input, an error that shows up only as a red border, a date picker that opens a small popover under your thumb, a custom select that the browser's autofill skips. This guide treats React form components as one set: which controls a product needs, the anatomy they should share, how to wire them to react-hook-form and zod, and what each one should do on a phone. The examples use Wingo UI, the library we build, so we are not neutral; outside facts link to their sources and are dated.
Which form controls does a product actually need?
The fifteen kinds of input in the table below cover the forms most products ship. Pick each one by what the person is entering, then check what it does on a phone. Most React input components look alike on a desktop. They split apart at 390px, where a popover has no room and a 32px target is too small for a thumb.
In the catalog these sit in two categories, Inputs with 36 items and Forms with 8, as of release 1.7.0. Two choices in this table are easy to get wrong, so here is where we land.
Choosing one option is three different controls. If people should compare the options at a glance (shipping speed, plan, size), show them all with a radio group or a segmented control; a select hides the choice behind a tap and saves nothing at four options. A select fits a known list that people can scan: states, categories, a few hundred time zones. A combobox fits when typing is faster than scrolling, the list comes from a server, or a free text answer is allowed.
Numbers are the other trap. A native type="number" field ignores maxLength (MDN lists the attribute (opens in a new tab) only for text, search, url, tel, email and password inputs), accepts exponent input such as 1e5, and in some desktop browsers a scroll over the focused field changes its value. Our Input renders type="number" as a text field with a decimal keypad, so maxLength works and a scroll never changes the value.
What anatomy should every form field share?
Every field should have the same three parts in the same order: a label above the control, the control's box, and one message row under it. The message row shows either the hint or the error, never both, and a counter at its end when the field has a limit. In accessible form fields, the parts people forget are the ones nobody sees: the ids, the live region and what a screen reader hears.
- Label. A real
<label>withhtmlForpointing at the control's id, so clicking it focuses the field and screen readers name the control. The required mark after it is hidden from screen readers, because the control itself carriesrequired. When most fields are required, mark the few optional ones instead. - Hint. Helper text under the box, linked with
aria-describedby, so it is read after the label. - Error. It replaces the hint. The control's
aria-describedbyswitches to the error's id,aria-invalidturns on, and the box gets a red ring plus an icon and the text. WCAG 2.2 success criterion 3.3.1 (opens in a new tab) (Level A) requires that an automatically detected error is identified and "described to the user in text", so a red border alone fails. - Counter. "12 / 70" at the end of the message row, amber from 90% and red at the limit. The visible counter is hidden from screen readers, which instead hear "7 characters left" once 10% or less remains, half a second after typing stops. Reading the count on every keystroke is noise.
Swapping the hint for the error, instead of stacking the error under it, means a field with a hint barely changes height when the error appears. The row opens and closes with a short collapse animation and the hint crossfades into the error, so the fields under it glide instead of jumping. The row stays mounted as a polite live region, which means a new error is announced once, without stealing focus.
In Wingo UI the anatomy is the free Field. You can wrap any native control in it, and your own components can join it with one hook:
FieldControl puts the Field's id, aria-describedby, aria-invalid and aria-required on its child, and the child's own props win. useFieldControl returns the same ids and flags for a custom control, plus wrap(), which draws the label and message row around the control when it has its own label or error, and does nothing when a parent Field already does. Pass required, disabled and readOnly through the hook, not straight onto the input: controlProps always carries those keys, so spreading it after your own props would reset them to undefined.
$ npx wingo-ui@latest add fieldThe demo is a listing form with a 70 character title, a required price with a USD suffix and a city. The counter turns amber near the limit and the Continue button moves focus to the first field that needs fixing.
Libraries differ in how much of this wiring they do for you. As of October 2026, the shadcn/ui Field (opens in a new tab) is a set of layout parts: you connect FieldLabel htmlFor to the input's id yourself, put data-invalid on the Field and aria-invalid on the control, and pass messages to FieldError, which also accepts an errors array from react-hook-form. React Aria's useField (opens in a new tab) goes the other way and "takes care of creating ids for each element and associating them with the correct ARIA attributes." Our Field sits closer to React Aria: the control reads the ids and flags from context. The explicit shadcn approach has one real advantage: there is no hidden context to debug, and if your team already lives in shadcn/ui it is a fine choice. We chose context because 31 of our controls take the same error prop and have to produce the same markup every time.
Why should every control take label, description and error props?
Because a form then reads as a flat list of fields, and swapping one control for another never changes the wiring. In Wingo UI, 31 controls as of release 1.7.0 (Input, Textarea, Select, Combobox, Phone Input, the date pickers, Checkbox, Switch, Radio Group, Slider and the rest) take label, description and error directly, most of them also required, invalid, disabled and readOnly, and render the Field anatomy themselves:
Four different controls, one shape. Anything the Field takes beyond those props (an optional mark, a label action such as "Forgot your password?", a horizontal layout with a label column) goes through fieldProps, which Input, Select, Phone Input, Date Picker and most other controls accept. Sections of a form go in a FieldGroup, which is a real <fieldset> with a <legend>, so a screen reader announces "Shipping address" before the first field of the group. A disabled FieldGroup disables every field inside it, and its columns prop gives one column on phones and two from 640px.
Two details in this anatomy save real bugs. A click on a Select's label focuses the trigger without clicking it, so the word "State" never pops a list open. And the name prop on each of these four controls submits a value through a native or hidden input, which matters more than it sounds; there is a section on it below.
How do you wire react-hook-form and zod to these components?
Use register for controls that render a native input and pass the native onChange, onBlur and ref through, and Controller for everything else. Validate with one zod schema whose rules share one message set, and show errors once a field has been left. There is no special set of React Hook Form components to install: if a component forwards those three things to its focusable element, RHF can drive it.
The form-validation lib packages the form glue our blocks share: createValidators() returns zod rules with one set of messages (text, name, email, password, phone, url, otp, accept for a ticked checkbox, number, plus Romanian cui, iban and postalCode), useZodForm(schema) is useForm with the zod resolver and our timing, and fieldError(form, "email") turns a field's error into the string a component's error prop takes. Here is a delivery request form with seven fields:
Install the pieces with one command; the CLI brings react-hook-form, @hookform/resolvers, zod, libphonenumber-js and date-fns along:
The phone input, the date picker and the form-validation lib are Pro: run npx wingo-ui@latest login once with a Pro account, or the CLI stops and tells you to sign in. The code uses zod 4 and react-hook-form 7. A few lines in it carry more weight than they look.
Why validate once a field is left?
useZodForm sets mode: "onTouched" and reValidateMode: "onChange". The React Hook Form docs (opens in a new tab) describe onTouched as validation that "is initially triggered on the first blur event. After that, it is triggered on every change event." So nobody hears that their email is invalid after typing one letter, and once an error shows, it clears on the keystroke that fixes it. The same docs warn that mode: "onChange" "often comes with a significant impact on performance", which is a second reason to avoid it on long forms.
Why pass field.ref to every Controller?
shouldFocusError is on by default, so a failed submit moves focus to the first field with an error, but only if RHF holds a ref to something focusable. The Controller docs (opens in a new tab) say to assign field.ref "to allow hook form to focus the error input". In these components ref is a plain prop (React 19, no forwardRef) and it lands on the element a person would focus: the Select's trigger button, the Phone Input's number input, the Date Picker's text input, the Checkbox's button. Our React 19 forwardRef migration guide shows how to move your own fields to that pattern.
When is register not enough?
When the value is anything other than the string in a native input: an E.164 phone number, a Date, a boolean, a value picked from a list. Also when a text field shows a counter. Input and Textarea keep their length in their own state. They pick up a native form reset, but a value that RHF writes straight into the DOM with setValue() or reset(values) reaches the counter only when the field gets focus again. The notes field above goes through Controller for that reason, even though a native textarea sits inside it.
What about the date and the phone number?
The date picker reports null when cleared, so the form stores undefined instead and z.date({ error }) fails with the required message. A typed date reaches the form only once it is complete and allowed, so a typed Sunday never does. The phone input reports an E.164 string such as +12015550123 once the number is possible and an empty string before that. v.phone({ country: "US" }) checks it with libphonenumber-js. The country only matters for values without a calling code, such as a number typed into a plain Input, and it defaults to RO, so set it on a US form.
noValidate on the form turns off the browser's own bubbles, so the zod messages are the only ones people see. Without it, an empty field with required makes the browser block the submit with its own tooltip, and handleSubmit never runs to show your messages.
$ npx wingo-ui@latest add form-validationThe demo is a sign-up form built on the same lib: name, email, phone, an optional company tax ID, a password with a strength rule and a terms checkbox.
Should the server validate the same schema?
Yes, always: the client schema is a convenience for people, and anything that reaches your server action or API route has to be checked again. The Next.js forms guide (opens in a new tab) suggests parsing the submitted values with a schema library such as zod inside the Server Action. One catch with our lib today: lib/form-validation.ts starts with "use client" because it holds the useZodForm hook, so server code cannot call createValidators() from it. Write the server schema with zod directly, or, since the file is your source after install, move the rules into a module without the directive.
Do you need react-hook-form at all?
Not for a short form. If every control submits through a real input with a name, the browser's FormData already holds clean values, required fields block the submit natively, a form reset restores the defaults and autofill works. That is why the controls in this guide render something native when you pass name:
- Select renders a hidden native
<select>, soFormData,required, form reset and browser autofill work. MDN notes (opens in a new tab) that browsers may require anameoridand an owning form before they offer autofill, which is exactly what a select built from divs lacks. PassautoComplete="address-level1"(the state, in a US address) and the browser can fill it from a saved address. - Phone Input renders a hidden input with the E.164 number, while the visible input shows the formatted national digits.
- Date Picker renders a hidden input with the local ISO date (
2026-10-23, or2026-10-23T14:30with a time), never shifted to UTC, which avoids the off by one day bug thattoISOString()causes east of UTC. - Checkbox submits its
valuethrough Radix's hidden input while checked; Input and Textarea are native elements.
The same hidden inputs are what a Server Action receives when you pass it to <form action>, because the action is called with the form's FormData. Our rule of thumb: plain FormData for forms of up to five or six fields with simple rules; react-hook-form once you need cross-field rules (a password and its confirmation, which withMatch in the lib handles), errors that update while typing, dirty tracking for a "You have unsaved changes" prompt, or lists of fields people add and remove.
How should each control behave on a phone?
Fields you type into get 16px text, the right keypad and the right Enter key. Fields you choose from open a bottom sheet instead of a popover. Nothing you tap has a hit area under 44px. The broader rulebook is in our guide to mobile-first React components; here is how it applies control by control.
Text fields: 16px, the keypad and the Enter key
Safari on iOS zooms the page when you focus a field whose text is smaller than 16px, and it does not zoom back out on blur (Rick Strahl's write-up (opens in a new tab) has the details). The fix is the font size. Avoid maximum-scale=1 in the viewport meta tag: the same write-up notes that Android honors it and blocks pinch zoom, which hurts the people who rely on zooming. Every Wingo UI field box starts at text-base (16px) and switches to its desktop size from 768px up: text-base md:text-sm gives the md box 14px there.
The keypad follows type and inputMode: email adds the @ key, tel shows the phone pad, decimal shows digits and a separator. autoComplete with the right token (name, email, tel-national, new-password, postal-code) lets the browser fill the field, and enterKeyHint="next" on every field but the last ("done" there) labels the Enter key with what it will do. The md box is 40px with a mouse and 44px on touch screens through Tailwind's pointer-coarse variant, and the clear button keeps a 44px hit area even though it looks small.
Select and combobox: a sheet, and a keyboard that waits
Below 768px the Select opens a bottom sheet titled with the field's label, with 48px rows and the chosen row scrolled into view. A tap picks and closes. Lists of more than eight options get a search row at the top, but on a touch screen it waits for a tap before it takes focus, so the keyboard does not cover half the list you were about to scroll. The Combobox does the opposite on purpose: a tap opens a full-height sheet with the keyboard already up, because typing is the whole point of a combobox. Pass responsive={false} to keep the popover on phones, for example inside a sheet that is already full screen.
Dates and times: no keyboard
On a desktop, typing a date is faster than clicking one. The Date Picker reads "09232026" as 09/23/2026, "+3" as three days from today and "tomorrow" as tomorrow, and it formats the text the way the locale writes it. On a phone the field never raises the keyboard. A tap opens a sheet with one month of 44px days you can swipe between, plus a row of chips when you turn on presets (Today, Tomorrow, Next week, or your own). With withTime, the time field in the sheet opens its own wheel sheet. The Time Picker uses the same wheels, which snap like the iOS picker and give a short vibration tick per row where the browser supports the Vibration API.
Phone numbers: one clean value
The Phone Input raises the phone keypad, formats the number as the country writes it and keeps the caret after the digit you typed. Pasting or autofilling a number that starts with +44 or 0044 switches the country by itself. The flag button opens the country list, which is a bottom sheet with search on phones, and you can search by country name, ISO code or calling code. Whatever the person types, your form receives one E.164 string.
$ npx wingo-ui@latest add phone-inputOne-time codes: autofill first
The OTP Input puts autocomplete="one-time-code" on its first slot, which MDN describes (opens in a new tab) as the token for "a one-time password (OTP) for verifying user identity", so the browser can offer a code it received by text message. Slots raise the numeric keypad, a pasted code fills every slot at once, and Backspace clears a slot before it steps back.
It is also the one control in this guide that does not join the Field anatomy: it renders a row of single-character inputs, each named "Digit 1 of 6" and so on, and its error state is a red ring and a shake. Give the row a group name and put the error in text under it with FieldMessage, which works outside a Field:
We skip react-hook-form here. A code screen has one value and one rule, and the answer comes from the server, so onComplete and a status are all the state it needs. When you do need it, our React OTP input guide wires the code into react-hook-form and zod, explains why SMS autofill fails silently and adds a resend timer.
Numbers and money
The Number Input and the Currency Input read numbers the way the locale writes them: 1,234.5 in English, 1.234,5 in Romanian. The Number Input raises the numeric keypad, or the decimal one when step or decimals allows decimals, refuses keystrokes that can never become a valid number (a minus when min is 0 or more), and has steppers with a 44px hit area on touch. The Currency Input pads the cents on blur ("1.234,5" becomes "1.234,50") and shows quick amounts as chips through presets, which beat typing for tips and donations.
Test negative numbers on a real phone: MDN notes (opens in a new tab) that with the numeric and decimal input modes, "devices may or may not show a minus key". If yours does not, pass inputMode="text" to the Number Input or rely on the steppers.
When should a form show its errors?
Show an error after the person leaves the field, update it live while they fix it, and on submit show every error at once and move focus to the first. That is the onTouched timing from above, and it fits almost every form. A few rules around it:
- Never on load. An empty required field is not an error until someone has had a chance to fill it.
- Keep the submit button enabled. A disabled button gives no reason, and Tab skips it, so keyboard users cannot even reach it. Let the submit fail and show the errors.
- Check slow things without blocking. An Input with
loadingshows a spinner at the end of the box (in place of the trailing icon, if it has one), setsaria-busyand stays editable, so a username check runs while the person keeps typing. - Write errors as instructions. "Enter a valid email address" tells people what to do; "Invalid input" makes them guess. Most of the lib's English messages follow that pattern, and
createValidators(VALIDATION_MESSAGES_RO)swaps in the Romanian set in one line.
What should a React form components library give you?
A React form components library earns its place when it removes the wiring you would otherwise repeat in every field, and lets you change what it got wrong. Our checklist, which you can apply to any library, ours included (for prices and licenses, see our shadcn/ui alternatives comparison):
- One anatomy for every control, with the label, hint, error and counter ids wired for you, and a hook to bring your own controls into it.
- Native form participation. A
namethat ends up inFormData,requiredthat blocks the submit, a form reset that restores defaults, and autofill that reaches custom selects. - Controlled and uncontrolled modes, and a
refthat lands on the element a person would focus, so a form library can focus the first error. - Phone behavior built into the same component: 16px text, keypads, 44px targets, bottom sheets below 768px. A separate mobile component doubles every form.
- Source you own. Wingo UI copies the files into your project, like shadcn/ui, so you can change a label, a timing or a schema rule without waiting for a release, and
npx wingo-ui@latest updatemerges our later fixes into the files you edited. The same 3-way merge done by hand with git, for files from the shadcn CLI, is in our guide to updating shadcn components without losing edits.
Field, Input, Textarea, Select, Checkbox, Switch, Radio Group, Segmented Control and the OTP input are free. The phone input, the date and time pickers, Combobox, Multi-select, the number and currency inputs and the form-validation lib are part of Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 (see Wingo UI Pro pricing).
Which component guides go deeper?
These posts in the component guides category take one field, one rule or one component further:
- React OTP input: paste, SMS autofill, react-hook-form with zod and a resend timer.
- Romanian CUI validation: the company tax ID check digit, a zod rule and a lookup that proves the company exists.
- IBAN validation in JavaScript: the mod 97 check, why a regex is not enough and a field that formats as you type.
- Romanian CNP validator: decoding and validating a personal numeric code, and whether to store it at all.
- TanStack Table v9 data table: filters, bulk actions, pinning and server pagination, with cards on phones.
- React command palette: Cmd+K with nested pages, async results and a full-height sheet on phones.
- dnd kit on mobile: touch drag that still lets the page scroll, and a tap path for people who cannot drag.
- Updating shadcn components: pulling upstream fixes into files you already edited.
Where should you start?
Install the free anatomy and the controls most forms need, then build one real form with them:
The command copies the source files into your project, installs the npm packages they import and records the versions, so later updates can merge into your edits. In a project without a wingo-ui.json, run npx wingo-ui@latest init first. Open the Field docs for the playground and every prop, and add the phone, date and validation pieces when your forms need them.
FAQ
Do I need react-hook-form to build forms in React?
No. A short form works with native inputs, name attributes and FormData, and Wingo UI's Input, Textarea, Select, Checkbox, Phone Input and Date Picker all submit their value through a native or hidden input once they have a name. React Hook Form pays off once you need per-field validation timing, cross-field rules, dirty state or field arrays.
How do I use react-hook-form with custom components?
Use register when the component renders a native input and passes onChange, onBlur and ref through to it, like a text input. Use Controller for anything whose value is not a string in a native input (a select, a date, a phone number, a checkbox), and pass field.ref so a failed submit can focus it.
How do I make form fields accessible in React?
Tie every label to its control with htmlFor and id, point aria-describedby at the hint or, while it shows, the error, and set aria-invalid on the invalid control. Describe each error in text instead of color alone, announce it through a polite live region, and move focus to the first invalid field when a submit fails.
Why does my iPhone zoom in when I tap an input?
Safari on iOS zooms the page when the focused field's text is smaller than 16px. Give inputs 16px text on phones and go smaller from a wider breakpoint if you want, instead of turning off pinch zoom in the viewport meta tag.
Should I use a select, a radio group or a combobox?
Use a radio group or a segmented control for two to five options people should compare at a glance, a select for a known list up to a few hundred options, and a combobox when the list is huge, comes from a server or accepts free text.
Which Wingo UI form components are free?
Field, Input, Textarea, Select, Checkbox, Switch, Radio Group, Segmented Control, Search Input, Listbox and the OTP input are free. The phone input, the date and time pickers, Combobox, Multi-select, the number, currency, password and email inputs, File Upload and the form-validation lib are Pro.
- Forms
- React Hook Form
- Zod
- Accessibility
- Mobile UI
- React