TL;DR

Good React form components share one anatomy: a label tied to the control, one message row that shows the hint or the error, and a counter when there is a limit, with the ids and aria attributes wired for you. Connect them to react-hook-form with register for native inputs and Controller for everything else, and validate with one zod schema once a field has been left. On phones, every control needs 16px text, the right keypad, 44px targets and a bottom sheet instead of a popover.

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.

What people enter Component On a phone
Short text, email, URL Input (Free) 16px text, keypad from the type, 44px box
Long text Textarea (Free) Grows line by line, return makes a new line
One of 2 to 5 visible options Radio Group, Segmented Control (Free) 44px targets, a radio's whole row is one
One of a known list Select (Free) Bottom sheet with 48px rows
One of thousands, or from a server Combobox (Pro) Full-height sheet with its own search box
Several of a list Multi-select (Pro) Sheet with a "Done (3)" footer
Consent to terms Checkbox (Free) The whole row is a 44px target
A setting that applies at once Switch (Free) Drags like the iOS switch
A number or an amount Number Input, Currency Input (Pro) Numeric or decimal keypad, 44px steppers or amount chips
A date Date Picker (Pro) No keyboard: a sheet with 44px days
A time of day Time Picker (Pro) Wheels in a sheet
A phone number Phone Input (Pro) Phone keypad, country list in a sheet
A one-time code OTP Input (Free) Numeric keypad, code autofill
A password Password Input (Pro) 44px reveal button
Files and photos File Upload (Pro) "Tap to choose files", an optional camera button

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 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> with htmlFor pointing 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 carries required. 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-describedby switches to the error's id, aria-invalid turns on, and the box gets a red ring plus an icon and the text. WCAG 2.2 success criterion 3.3.1 (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:

"use client"; import type { ComponentProps } from "react"; import { FIELD_INPUT, Field, FieldAddon, FieldBox, FieldControl, useFieldControl } from "@/components/ui/field"; // any native control: Field draws the label and messages, FieldControl wires the ids and aria onto the input export function PriceField({ error }: { error?: string }) { return ( <Field label="Price" description="The final price, tax included." error={error} required> <FieldBox> <FieldControl> <input name="price" inputMode="decimal" placeholder="0" autoComplete="off" className={FIELD_INPUT} /> </FieldControl> <FieldAddon>USD</FieldAddon> </FieldBox> </Field> ); } type PlainInputProps = ComponentProps<"input"> & { label?: string; description?: string; error?: string }; // your own control: one hook, and it works alone or inside a <Field> export function PlainInput({ id, label, description, error, required, disabled, readOnly, ...props }: PlainInputProps) { // the flags go through the hook: controlProps sets id, required, disabled and readOnly on the input const field = useFieldControl({ id, label, description, error, required, disabled, readOnly }); return field.wrap( <FieldBox invalid={field.invalid} disabled={field.disabled} readOnly={field.readOnly}> <input {...props} {...field.controlProps} className={FIELD_INPUT} /> </FieldBox>, ); } 

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.

Live demo: Field. The original article embeds an interactive demo here (Press Continue with a title under 10 characters: the error replaces the hint and the fields below glide down; type and it slides away again). Try it live on wingo-ui.com or open the Field docs. Install: npx wingo-ui@latest add field

The 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 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 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:

"use client"; import { useState } from "react"; import { DatePicker } from "@/components/ui/date-picker"; import { Input } from "@/components/ui/input"; import { PhoneInput } from "@/components/ui/phone-input"; import { Select } from "@/components/ui/select"; const STATES = [ { value: "CA", label: "California" }, { value: "NY", label: "New York" }, { value: "TX", label: "Texas" }, ]; export function SameAnatomy() { const [title, setTitle] = useState(""); return ( <div className="flex flex-col gap-4"> <Input label="Listing title" description="Brand, model and condition." value={title} onValueChange={setTitle} error={title && title.length < 10 ? "The title needs at least 10 characters." : undefined} showCount maxLength={70} required /> <Select label="State" options={STATES} description="We ship to these states only." required /> <PhoneInput label="Phone" defaultCountry="US" description="Only for delivery updates." /> <DatePicker label="Delivery date" locale="en-US" error="We do not deliver on Sundays." /> </div> ); } 

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:

"use client"; import { Controller } from "react-hook-form"; import { z } from "zod"; import { addDays, startOfToday } from "date-fns"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DatePicker } from "@/components/ui/date-picker"; import { Input } from "@/components/ui/input"; import { PhoneInput } from "@/components/ui/phone-input"; import { Select, type SelectOption } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { createValidators, fieldError, useZodForm } from "@/lib/form-validation"; const v = createValidators(); const STATES: SelectOption[] = [ { value: "CA", label: "California", keywords: ["CA"] }, { value: "NY", label: "New York", keywords: ["NY"] }, { value: "TX", label: "Texas", keywords: ["TX"] }, { value: "WA", label: "Washington", keywords: ["WA"] }, ]; const schema = z.object({ name: v.name(), email: v.email(), phone: v.phone({ country: "US" }), state: v.text(), deliveryDate: z.date({ error: v.messages.required }), notes: v.text({ required: false, max: 200 }), terms: v.accept(), }); export type DeliveryRequest = z.output<typeof schema>; export function DeliveryForm({ onSave }: { onSave: (values: DeliveryRequest) => Promise<void> }) { const form = useZodForm(schema, { defaultValues: { name: "", email: "", phone: "", state: "", notes: "", terms: false }, }); return ( <form onSubmit={form.handleSubmit(onSave)} noValidate className="flex flex-col gap-4"> {/* native inputs inside: register is enough */} <Input label="Full name" autoComplete="name" required {...form.register("name")} error={fieldError(form, "name")} /> <Input label="Email" type="email" autoComplete="email" required {...form.register("email")} error={fieldError(form, "email")} /> {/* everything else: Controller, with the ref so a failed submit can focus it */} <Controller control={form.control} name="phone" render={({ field }) => ( <PhoneInput label="Phone" defaultCountry="US" preferredCountries={["US", "CA"]} required ref={field.ref} name={field.name} value={field.value} onValueChange={(e164) => field.onChange(e164)} onBlur={field.onBlur} error={fieldError(form, "phone")} /> )} /> <Controller control={form.control} name="state" render={({ field }) => ( <Select label="State" placeholder="Choose the state" options={STATES} required ref={field.ref} name={field.name} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} error={fieldError(form, "state")} /> )} /> <Controller control={form.control} name="deliveryDate" render={({ field }) => ( <DatePicker label="Delivery date" locale="en-US" min={addDays(startOfToday(), 1)} disabled={{ dayOfWeek: [0] }} required ref={field.ref} name={field.name} value={field.value ?? null} onValueChange={(date) => field.onChange(date ?? undefined)} onBlur={field.onBlur} error={fieldError(form, "deliveryDate")} /> )} /> <Controller control={form.control} name="notes" render={({ field }) => ( <Textarea label="Notes for the driver" maxLength={200} showCount ref={field.ref} name={field.name} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} error={fieldError(form, "notes")} /> )} /> <Controller control={form.control}Source:https://dev.to/wingo-ui/react-form-components-accessible-fields-zod-and-mobile-3l1i

Related posts