# React Form Components: Accessible Fields, Zod and Mobile

> React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 21 min
- Canonical: https://wingo-ui.com/blog/react-form-components-guide

## 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](https://wingo-ui.com/components/input) (Free) | 16px text, keypad from the `type`, 44px box |
| Long text | [Textarea](https://wingo-ui.com/components/textarea) (Free) | Grows line by line, return makes a new line |
| One of 2 to 5 visible options | [Radio Group](https://wingo-ui.com/components/radio-group), [Segmented Control](https://wingo-ui.com/components/segmented-control) (Free) | 44px targets, a radio's whole row is one |
| One of a known list | [Select](https://wingo-ui.com/components/select) (Free) | Bottom sheet with 48px rows |
| One of thousands, or from a server | [Combobox](https://wingo-ui.com/components/combobox) (Pro) | Full-height sheet with its own search box |
| Several of a list | [Multi-select](https://wingo-ui.com/components/multi-select) (Pro) | Sheet with a "Done (3)" footer |
| Consent to terms | [Checkbox](https://wingo-ui.com/components/checkbox) (Free) | The whole row is a 44px target |
| A setting that applies at once | [Switch](https://wingo-ui.com/components/switch) (Free) | Drags like the iOS switch |
| A number or an amount | [Number Input](https://wingo-ui.com/components/number-input), [Currency Input](https://wingo-ui.com/components/currency-input) (Pro) | Numeric or decimal keypad, 44px steppers or amount chips |
| A date | [Date Picker](https://wingo-ui.com/components/date-picker) (Pro) | No keyboard: a sheet with 44px days |
| A time of day | [Time Picker](https://wingo-ui.com/components/time-picker) (Pro) | Wheels in a sheet |
| A phone number | [Phone Input](https://wingo-ui.com/components/phone-input) (Pro) | Phone keypad, country list in a sheet |
| A one-time code | [OTP Input](https://wingo-ui.com/components/otp-input) (Free) | Numeric keypad, code autofill |
| A password | [Password Input](https://wingo-ui.com/components/password-input) (Pro) | 44px reveal button |
| Files and photos | [File Upload](https://wingo-ui.com/components/file-upload) (Pro) | "Tap to choose files", an optional camera button |

In the catalog these sit in two categories, [Inputs](https://wingo-ui.com/components/category/inputs) with 36 items and [Forms](https://wingo-ui.com/components/category/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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#attributes) 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](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) (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](https://wingo-ui.com/components/field). You can wrap any native control in it, and your own components can join it with one hook:

```tsx
"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): 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 at [Field](https://wingo-ui.com/components/field) and install it with `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](https://ui.shadcn.com/docs/components/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](https://react-aria.adobe.com/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:

```tsx
"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](https://wingo-ui.com/components/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:

```tsx
"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}
        name="terms"
        render={({ field }) => (
          <Checkbox
            label="I accept the delivery terms"
            ref={field.ref}
            name={field.name}
            checked={field.value === true}
            onCheckedChange={(checked) => field.onChange(checked === true)}
            onBlur={field.onBlur}
            error={fieldError(form, "terms")}
          />
        )}
      />
      <Button type="submit" size="lg" fullWidth loading={form.formState.isSubmitting}>
        Request delivery
      </Button>
    </form>
  );
}
```

Install the pieces with one command; the CLI brings react-hook-form, `@hookform/resolvers`, zod, libphonenumber-js and date-fns along:

```bash
npx wingo-ui@latest add form-validation input select phone-input date-picker textarea checkbox
```

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](https://react-hook-form.com/docs/useform) 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](https://react-hook-form.com/docs/usecontroller/controller) 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](https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop) 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.

> Live demo (Form validation): Submit the empty form: every error shows at once and focus jumps to the first one; fix a field and its error clears as you type. Try it at [Form validation](https://wingo-ui.com/components/form-validation) and install it with `npx wingo-ui@latest add form-validation`.

The 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](https://nextjs.org/docs/app/guides/forms) 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>`, so `FormData`, `required`, form reset and browser autofill work. [MDN notes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) that browsers may require a `name` or `id` and an owning form before they offer autofill, which is exactly what a select built from divs lacks. Pass `autoComplete="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`, or `2026-10-23T14:30` with a time), never shifted to UTC, which avoids the off by one day bug that `toISOString()` causes east of UTC.
- **Checkbox** submits its `value` through Radix's hidden input while checked; **Input** and **Textarea** are native elements.

```tsx
"use client";

import type { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
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 QuoteForm({ onQuote }: { onQuote: (values: Record<string, FormDataEntryValue>) => void }) {
  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // { phone: "+12015550123", state: "NY", pickup: "2026-10-23" }
    onQuote(Object.fromEntries(new FormData(event.currentTarget)));
  };

  return (
    <form onSubmit={submit} className="flex flex-col gap-4">
      <PhoneInput name="phone" label="Phone" defaultCountry="US" required />
      <Select name="state" label="State" options={STATES} autoComplete="address-level1" required />
      <DatePicker name="pickup" label="Pickup date" locale="en-US" />
      <Button type="submit">Get a quote</Button>
    </form>
  );
}
```

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](https://wingo-ui.com/blog/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](https://weblog.west-wind.com/posts/2023/Apr/17/Preventing-iOS-Textbox-Auto-Zooming-and-ViewPort-Sizing) 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.

> Live demo (Phone Input): Type a US number, then type or paste one that starts with +44: the flag switches to the United Kingdom, and the readout shows the clean value the form submits. Try it at [Phone Input](https://wingo-ui.com/components/phone-input) and install it with `npx wingo-ui@latest add phone-input`.

### One-time codes: autofill first

The [OTP Input](https://wingo-ui.com/components/otp-input) puts `autocomplete="one-time-code"` on its first slot, which [MDN describes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) 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:

```tsx
"use client";

import { useState } from "react";
import { FieldMessage } from "@/components/ui/field";
import OtpInput, { type OtpStatus } from "@/components/ui/otp-input";

export function VerifyPhone({ phone, verify }: { phone: string; verify: (code: string) => Promise<boolean> }) {
  const [status, setStatus] = useState<OtpStatus>("idle");

  return (
    <div className="flex flex-col gap-3">
      <p id="otp-title" className="text-sm text-muted-foreground">
        Enter the 6 digit code we sent to {phone}.
      </p>
      <OtpInput
        role="group"
        aria-labelledby="otp-title"
        length={6}
        status={status}
        autoFocus
        onChange={() => setStatus("idle")}
        onComplete={async (code) => setStatus((await verify(code)) ? "success" : "error")}
      />
      <FieldMessage error={status === "error" ? "That code is not right. Check the latest text message." : undefined} />
    </div>
  );
}
```

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](https://wingo-ui.com/blog/react-otp-input) 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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode) 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 `loading` shows a spinner at the end of the box (in place of the trailing icon, if it has one), sets `aria-busy` and 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](https://wingo-ui.com/blog/shadcn-alternatives)):

1. **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.
2. **Native form participation.** A `name` that ends up in `FormData`, `required` that blocks the submit, a form reset that restores defaults, and autofill that reaches custom selects.
3. **Controlled and uncontrolled modes**, and a `ref` that lands on the element a person would focus, so a form library can focus the first error.
4. **Phone behavior built into the same component**: 16px text, keypads, 44px targets, bottom sheets below 768px. A separate mobile component doubles every form.
5. **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 update` merges 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](https://wingo-ui.com/blog/update-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](https://wingo-ui.com/pricing)).

## Which component guides go deeper?

These posts in the [component guides category](https://wingo-ui.com/blog/category/components) take one field, one rule or one component further:

- [React OTP input](https://wingo-ui.com/blog/react-otp-input): paste, SMS autofill, react-hook-form with zod and a resend timer.
- [Romanian CUI validation](https://wingo-ui.com/blog/romanian-cui-validation-javascript): the company tax ID check digit, a zod rule and a lookup that proves the company exists.
- [IBAN validation in JavaScript](https://wingo-ui.com/blog/iban-validation-javascript): the mod 97 check, why a regex is not enough and a field that formats as you type.
- [Romanian CNP validator](https://wingo-ui.com/blog/romanian-cnp-validator-javascript): decoding and validating a personal numeric code, and whether to store it at all.
- [TanStack Table v9 data table](https://wingo-ui.com/blog/tanstack-table-v9-data-table): filters, bulk actions, pinning and server pagination, with cards on phones.
- [React command palette](https://wingo-ui.com/blog/react-command-palette): Cmd+K with nested pages, async results and a full-height sheet on phones.
- [dnd kit on mobile](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag): touch drag that still lets the page scroll, and a tap path for people who cannot drag.
- [Updating shadcn components](https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits): 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:

```bash
npx wingo-ui@latest add field input textarea select checkbox
```

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](https://wingo-ui.com/components/field) for the playground and every prop, and add the phone, date and validation pieces when your forms need them.

## Components in this post

- [Field](https://wingo-ui.com/components/field) (Free): The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use. Install: `npx wingo-ui@latest add field`
- [Input](https://wingo-ui.com/components/input) (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: `npx wingo-ui@latest add input`
- [Select](https://wingo-ui.com/components/select) (Free): A custom select with search, groups, icons, thousands of virtualized rows and a hidden native select for forms. Install: `npx wingo-ui@latest add select`
- [OTP Input](https://wingo-ui.com/components/otp-input) (Free): A one-time-code input whose characters roll into place behind a caret that slides from slot to slot. Install: `npx wingo-ui@latest add otp-input`
- [Phone Input](https://wingo-ui.com/components/phone-input) (Pro): A phone number field with a searchable country picker, formatting as you type, validation and one clean E.164 value out. Install: `npx wingo-ui@latest add phone-input`
- [Date Picker](https://wingo-ui.com/components/date-picker) (Pro): A date field to type in your locale or pick from a calendar popover, with presets, limits and an optional time; a sheet on phones. Install: `npx wingo-ui@latest add date-picker`
- [Form validation](https://wingo-ui.com/components/form-validation) (Pro): Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks. Install: `npx wingo-ui@latest add form-validation`
- [Textarea](https://wingo-ui.com/components/textarea) (Free): Multi-line text that grows with what you write, with a counter, an optional resize handle, a toolbar slot and mod+enter to submit. Install: `npx wingo-ui@latest add textarea`
- [Checkbox](https://wingo-ui.com/components/checkbox) (Free): A box that is on, off or mixed with a check that draws itself, a label row that is the whole target, cards, and groups with select all. Install: `npx wingo-ui@latest add checkbox`

## 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.

---

Source: https://wingo-ui.com/blog/react-form-components-guide
