Skip to content

shadcn.io is an independent project built on shadcn/ui

Guides

Form

Build validated React forms: React Hook Form or TanStack Form holds the values, a Zod schema checks them, and Field draws each label, hint and error.

There's no Form component to install. A form is three parts working together: a form library that holds the values and runs validation, a Zod (opens in a new tab) schema that states the rules, and Field, which lays out each control with its label, description and error. This guide builds one with React Hook Form, then shows the same form on TanStack Form.

It's the approach shadcn/ui takes today. If you're coming from its older Form and FormField components, the table below maps each one to what replaces it.

A bug report with a title and a description that counts its characters. Submit it empty and each field turns red with its own message under it. Fill both in correctly and the toast prints the values the schema accepted. Reset puts both fields back to empty.

Install

React Hook Form holds the state, @hookform/resolvers connects it to Zod, and Zod checks the values:

bun add react-hook-form @hookform/resolvers zod

Then add the components the form draws with, each from its page, or several in one command:

shadcn CLILocked
Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

The code on this page is for React Hook Form 7, @hookform/resolvers 5 and Zod 4, with the components on Base UI. In a Next.js App Router project, the form is a Client Component: start its file with "use client".

How the pieces fit

Each field is a Controller from React Hook Form with a Field inside it. The Controller gives the control its value and handlers (field) and gives the layout its state (fieldState):

<Controller
  control={form.control}
  name="title"
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel htmlFor="bug-title">Bug title</FieldLabel>
      <Input {...field} autoComplete="off" id="bug-title" />
      <FieldDescription>Between 5 and 32 characters.</FieldDescription>
      <FieldError>{fieldState.error?.message}</FieldError>
    </Field>
  )}
/>
PartFromWhat it does
useFormReact Hook FormHolds the values, runs the resolver, handles submit
ControllerReact Hook FormConnects one control: value, onChange, onBlur, name and ref in field, the error in fieldState
zodResolver@hookform/resolversRuns the Zod schema and turns its issues into field errors
Fieldshadcn.ioStacks the label, the control and the text; invalid turns it red and marks the control inside
FieldLabelshadcn.ioA <label>; its htmlFor matches the control's id
FieldDescriptionshadcn.ioHelper text for the control
FieldErrorshadcn.ioThe message, with role="alert"; renders nothing when there's no message

Around the fields, FieldGroup stacks them with even spacing, and FieldSet with FieldLegend groups the options of one question, such as a set of checkboxes. FieldContent keeps a label and its description together beside a control, FieldTitle names a choice card without a second label, and FieldSeparator draws a rule between unrelated questions. The Field page has a demo of each.

Coming from FormField

shadcn/ui's older form.tsx has Form, FormField, FormItem, FormLabel, FormControl, FormDescription and FormMessage: wrappers around React Hook Form's context. shadcn/ui's docs now build forms with Field instead, and shadcn.io doesn't ship form.tsx at all. Each part has a direct replacement:

Old componentUse instead
<Form {...form}>Nothing. Pass control={form.control} to each Controller, and keep the native <form onSubmit={form.handleSubmit(onSubmit)}>
FormFieldReact Hook Form's Controller, with the same control, name and render (FormField was a Controller)
FormItemField, with invalid={fieldState.invalid}
FormLabelFieldLabel, with htmlFor set to the control's id
FormControlNothing. Put the id on the control; Field sets aria-invalid on the Base UI control inside
FormDescriptionFieldDescription
FormMessageFieldError, with fieldState.error?.message as its child
useFormField()The field and fieldState that render receives

The change is mostly renaming. A field that read <FormField render={() => <FormItem>…</FormItem>} /> becomes <Controller render={() => <Field>…</Field>} />, and the label, description and message keep their place. If components deep in the tree need the form, React Hook Form's own FormProvider and useFormContext still work: wrap the <form> in <FormProvider {...form}>. The fields themselves don't need it.

Build the form

Write the schema

The schema holds the rules and their messages in one place, and z.infer turns it into the type of the values it accepts:

components/bug-report-form.tsx
import * as z from "zod";

const formSchema = z.object({
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
});

Create the form

useForm takes the resolver and a default for every field. Give each one a default: reset() returns to these values, and an input whose value starts as undefined switches from uncontrolled to controlled the first time someone types, which React warns about.

components/bug-report-form.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";

export const BugReportForm = () => {
  const form = useForm({
    defaultValues: { description: "", title: "" },
    resolver: zodResolver(formSchema),
  });

  const onSubmit = (data: z.infer<typeof formSchema>) => {
    // Runs only when the schema passes: send `data` to your API.
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* The fields go here. */}
    </form>
  );
};

Add the fields

One Controller per field, each with a Field inside, in a FieldGroup:

components/bug-report-form.tsx
<form onSubmit={form.handleSubmit(onSubmit)}>
  <FieldGroup>
    <Controller
      control={form.control}
      name="title"
      render={({ field, fieldState }) => (
        <Field invalid={fieldState.invalid}>
          <FieldLabel htmlFor="bug-title">Bug title</FieldLabel>
          <Input
            {...field}
            autoComplete="off"
            id="bug-title"
            placeholder="Login button not working on mobile"
          />
          <FieldError>{fieldState.error?.message}</FieldError>
        </Field>
      )}
    />
    <Controller
      control={form.control}
      name="description"
      render={({ field, fieldState }) => (
        <Field invalid={fieldState.invalid}>
          <FieldLabel htmlFor="bug-description">Description</FieldLabel>
          <Textarea
            {...field}
            aria-invalid={fieldState.invalid}
            id="bug-description"
            rows={6}
          />
          <FieldDescription>
            Include steps to reproduce, expected behavior, and what actually
            happened.
          </FieldDescription>
          <FieldError>{fieldState.error?.message}</FieldError>
        </Field>
      )}
    />
  </FieldGroup>
</form>

The demo puts the description in an Input Group with an InputGroupTextarea and a counter under it, {field.value.length}/100 characters, which reads the value React Hook Form already holds.

Submit and reset

components/bug-report-form.tsx
<div className="flex gap-2">
  <Button onClick={() => form.reset()} variant="outline">
    Reset
  </Button>
  <Button type="submit">Submit</Button>
</div>

handleSubmit validates first. When the schema passes, it calls onSubmit with the parsed values; when it fails, it fills each field's fieldState.error and focuses the first invalid field. reset() puts every field back to its default and clears the errors.

Validation

When it runs

mode on useForm sets when a field is validated the first time:

const form = useForm({
  defaultValues: { description: "", title: "" },
  mode: "onTouched",
  resolver: zodResolver(formSchema),
});
ModeValidates
"onSubmit"On submit (the default)
"onBlur"When a field loses focus
"onChange"On every change
"onTouched"On the first blur, then on every change
"all"On blur and on every change

After a failed submit, each field validates again as it changes (reValidateMode, "onChange" by default), so an error clears as soon as it's fixed. "onTouched" suits long forms: nothing turns red while someone is still typing a first answer, and a fixed error clears at once.

Showing errors

An error shows in three ways, and Field handles two of them:

  • invalid on Field sets data-invalid, which turns the field's text the destructive color, and it renders Base UI's field, which sets aria-invalid on the Base UI control inside: Input, Select, Checkbox, Radio Group and Switch. The control draws its destructive border from that attribute.
  • FieldError shows the message with role="alert", so screen readers announce it when it appears. It renders nothing without a message, so you can leave it in place for every field.
  • A native control needs the attribute from you: Textarea takes aria-invalid={fieldState.invalid}, and Native Select takes invalid={fieldState.invalid}.

FieldError also takes errors, an array of { message } objects: one renders as text, several as a list, and a repeated message once. errors={[fieldState.error]} works the same as passing the message as a child.

Wire each control

An Input takes the whole field spread. Every other control has its own value and change callback, so you pass field.value and call field.onChange yourself.

Select

A spoken-language picker that refuses Auto: the schema rejects an empty value and the "auto" option, each with its own message.

<Controller
  control={form.control}
  name="language"
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel htmlFor="language">Spoken language</FieldLabel>
      <Select
        items={languages}
        name={field.name}
        onValueChange={(next) => field.onChange(next ?? "")}
        value={field.value || null}
      >
        <SelectTrigger id="language">
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent>
          {languages.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      <FieldError>{fieldState.error?.message}</FieldError>
    </Field>
  )}
/>

Base UI's Select means "nothing chosen" with null, so value={field.value || null} maps the form's empty string to it, and onValueChange maps null back. items (an array of { label, value }) lets SelectValue show the label instead of the raw value. The id goes on SelectTrigger, the part that takes focus.

Checkbox group

Two groups: one disabled checkbox bound to a boolean, and a list of notification types bound to an array of ids, which must have at least one.

<Controller
  control={form.control}
  name="tasks"
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldSet>
        <FieldLegend variant="label">Tasks</FieldLegend>
        <FieldGroup data-slot="checkbox-group">
          {tasks.map((task) => (
            <Field
              invalid={fieldState.invalid}
              key={task.id}
              orientation="horizontal"
            >
              <Checkbox
                checked={field.value.includes(task.id)}
                id={`task-${task.id}`}
                name={field.name}
                onCheckedChange={(checked) =>
                  field.onChange(
                    checked
                      ? [...field.value, task.id]
                      : field.value.filter((id) => id !== task.id)
                  )
                }
              />
              <FieldLabel htmlFor={`task-${task.id}`}>{task.label}</FieldLabel>
            </Field>
          ))}
        </FieldGroup>
      </FieldSet>
      <FieldError>{fieldState.error?.message}</FieldError>
    </Field>
  )}
/>

The schema is z.array(z.string()).min(1, "Please select at least one notification type."). FieldSet and FieldLegend name the group for screen readers, orientation="horizontal" puts each box beside its label, and data-slot="checkbox-group" tightens the gap between the rows. A single boolean checkbox is simpler: checked={field.value} and onCheckedChange={field.onChange}.

Radio group

Three plans as choice cards: each whole card selects its radio, and Save with none picked shows "You must select a subscription plan to continue."

<RadioGroup
  name={field.name}
  onValueChange={(value) => field.onChange(value as string)}
  value={field.value}
>
  {plans.map((plan) => (
    <FieldLabel htmlFor={`plan-${plan.id}`} key={plan.id}>
      <Field invalid={fieldState.invalid} orientation="horizontal">
        <RadioGroupItem id={`plan-${plan.id}`} value={plan.id} />
        <FieldContent>
          <FieldTitle>{plan.title}</FieldTitle>
          <FieldDescription>{plan.description}</FieldDescription>
        </FieldContent>
      </Field>
    </FieldLabel>
  ))}
</RadioGroup>

FieldLabel wrapping the Field makes the whole card the click target, and FieldTitle names it without nesting a second label. Put the group in a FieldSet with a FieldLegend, as the demo does, so the question is announced with the options.

Switch

A multi-factor authentication setting whose schema only accepts true, to show an error on a switch.

<Controller
  control={form.control}
  name="twoFactor"
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid} orientation="horizontal">
      <FieldContent>
        <FieldLabel htmlFor="two-factor">Multi-factor authentication</FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        <FieldError>{fieldState.error?.message}</FieldError>
      </FieldContent>
      <Switch
        checked={field.value}
        id="two-factor"
        name={field.name}
        onCheckedChange={field.onChange}
      />
    </Field>
  )}
/>

FieldContent keeps the label, the description and the error together on one side, with the switch on the other.

Other controls

The pattern is the same for every control: field.value in, field.onChange from its change callback, converting the value where the control's type differs from the schema's. Native Select takes the field spread like an input. Slider, Number Input, Combobox, Autocomplete, Date Picker, Input OTP, Rating and File Upload each name their callback and its value on their page.

A complete form

A subscription form that puts it together: plan cards (a radio group), a billing period (a select), up to three add-ons (a checkbox group with a minimum and a maximum), and email notifications (a switch), split by FieldSeparator. One schema validates all four:

const formSchema = z.object({
  addons: z
    .array(z.string())
    .min(1, "Please select at least one add-on.")
    .max(3, "You can select up to 3 add-ons."),
  billingPeriod: z.string().min(1, "Please select a billing period."),
  emailNotifications: z.boolean(),
  plan: z.enum(["basic", "pro"], {
    message: "Please select a subscription plan.",
  }),
});

Lists of fields

Up to five contact emails: each row has its own input and error, a remove button appears once there are two, and Add email address stops at five. React Hook Form's useFieldArray keeps the list:

const formSchema = z.object({
  emails: z
    .array(z.object({ address: z.email("Enter a valid email address.") }))
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
});

const form = useForm({
  defaultValues: { emails: [{ address: "" }] },
  resolver: zodResolver(formSchema),
});

const { append, fields, remove } = useFieldArray({
  control: form.control,
  name: "emails",
});

Render one Controller per item, keyed by item.id (not the index, or React mixes up the inputs when one is removed), with the index in the name:

{fields.map((item, index) => (
  <Controller
    control={form.control}
    key={item.id}
    name={`emails.${index}.address`}
    render={({ field, fieldState }) => (
      <Field invalid={fieldState.invalid}>
        <InputGroup>
          <InputGroupInput
            {...field}
            aria-label={`Email ${index + 1}`}
            autoComplete="email"
            placeholder="name@example.com"
            type="email"
          />
          {fields.length > 1 ? (
            <InputGroupAddon align="inline-end">
              <InputGroupButton
                aria-label={`Remove email ${index + 1}`}
                onClick={() => remove(index)}
                size="icon-xs"
                variant="ghost"
              >
                <XIcon aria-hidden />
              </InputGroupButton>
            </InputGroupAddon>
          ) : null}
        </InputGroup>
        <FieldError>{fieldState.error?.message}</FieldError>
      </Field>
    )}
  />
))}
<Button
  disabled={fields.length >= 5}
  onClick={() => append({ address: "" })}
  size="sm"
  variant="outline"
>
  Add email address
</Button>

An error on the list itself, from .min() or .max(), lands on form.formState.errors.emails?.root (on errors.emails itself when the list is empty); the demo reads both and shows the message in a FieldError under the list. Zod 4 writes an email check as z.email(); z.string().email() still works but is deprecated.

Validate on the server

Validation in the browser is for the person filling in the form; your server still checks every request. Put the schema in its own module so both sides import it:

app/bug-report/schema.ts
import * as z from "zod";

export const bugReportSchema = z.object({
  description: z.string().min(20, "Description must be at least 20 characters."),
  title: z.string().min(5, "Bug title must be at least 5 characters."),
});

export type BugReport = z.infer<typeof bugReportSchema>;

In Next.js, a Server Action runs the same schema and sends the messages back, keyed by field:

app/bug-report/actions.ts
"use server";

import * as z from "zod";
import { type BugReport, bugReportSchema } from "./schema";

export async function createBugReport(
  values: unknown
): Promise<{ errors: Partial<Record<keyof BugReport, string[]>> }> {
  const result = bugReportSchema.safeParse(values);
  if (!result.success) {
    return { errors: z.flattenError(result.error).fieldErrors };
  }
  // Save result.data here.
  return { errors: {} };
}

The form calls it from onSubmit and puts each message on its field with setError. handleSubmit awaits an async onSubmit, so formState.isSubmitting stays true until the server answers:

components/bug-report-form.tsx
const onSubmit = async (data: BugReport) => {
  const { errors } = await createBugReport(data);
  for (const [name, messages] of Object.entries(errors)) {
    form.setError(name as keyof BugReport, { message: messages?.[0] });
  }
};

<Button disabled={form.formState.isSubmitting} type="submit">
  Submit
</Button>

For an error that belongs to no field ("Couldn't save, try again"), call form.setError("root.server", { message }) and show form.formState.errors.root?.server?.message in a FieldError above the buttons.

TanStack Form

TanStack Form does the same job with a different API, and the same Field parts draw it. Install it with Zod:

bun add @tanstack/react-form zod

The same bug report, wired with TanStack Form. It takes the Zod schema directly, with no resolver, under validators; form.Field replaces Controller; and the form's submit handler calls form.handleSubmit() itself:

components/bug-report-form.tsx
"use client";

import { useForm } from "@tanstack/react-form";

export const BugReportForm = () => {
  const form = useForm({
    defaultValues: { description: "", title: "" },
    onSubmit: ({ value }) => {
      // Runs only when the schema passes: send `value` to your API.
    },
    validators: { onSubmit: formSchema },
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        form.handleSubmit();
      }}
    >
      <form.Field
        children={(field) => (
          <Field invalid={!field.state.meta.isValid}>
            <FieldLabel htmlFor="bug-title">Bug title</FieldLabel>
            <Input
              id="bug-title"
              name={field.name}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
              value={field.state.value}
            />
            <FieldError>
              {field.state.meta.errors.map((error) => error?.message).join(", ")}
            </FieldError>
          </Field>
        )}
        name="title"
      />
    </form>
  );
};
React Hook FormTanStack Form
Connect a controlController with renderform.Field with children
Read and write the valuefield.value, field.onChangefield.state.value, field.handleChange
InvalidfieldState.invalid!field.state.meta.isValid
MessagesfieldState.error?.messagefield.state.meta.errors, an array
Schemaresolver: zodResolver(schema)validators: { onSubmit: schema } (or onChange, onBlur)
ListsuseFieldArrayform.Field with mode="array", then pushValue and removeValue
Submit stateform.formState.isSubmittingform.Subscribe with selector={(state) => state.isSubmitting}
Resetform.reset()form.reset()

To validate earlier than submit, put the schema under onChange or onBlur in validators instead.

Accessibility

  • Every control has a name. A FieldLabel with htmlFor matching the control's id, or a FieldLabel wrapping the whole choice card. A control with no visible label, like each email in the list above, takes an aria-label.
  • Groups have a legend. Checkboxes and radios that answer one question go in a FieldSet with a FieldLegend, which screen readers announce with the options.
  • Invalid is announced. Field invalid sets aria-invalid on Base UI controls; add it yourself on Textarea and InputGroupTextarea, and pass invalid to NativeSelect. FieldError has role="alert", so the message is read when it appears.
  • Descriptions aren't attached for you. FieldDescription isn't tied to the control for you. To have a screen reader read it with the control, give it an id and pass the same value in the control's aria-describedby.
  • Focus moves to the error. On a failed submit, React Hook Form focuses the first invalid field (shouldFocusError, on by default) through field.ref. Spreading field on an Input passes it; on a control you wire by hand, pass ref={field.ref} to the part that takes focus, such as the Select's trigger.

Examples

The form examples are 42 finished forms on the same Field and React Hook Form pattern: login, sign-up, checkout and settings forms, plus validation patterns such as real-time validation, conditional validation and password strength.

FAQ

Is there a Form component in shadcn.io?

No. A form is a native <form>, a form library for state and validation, and Field for the layout. The older shadcn/ui Form, FormField, FormItem and FormMessage were wrappers around React Hook Form; React Hook Form's Controller with Field inside does the same job with one less layer. The mapping table lists the replacement for each part.

How do I migrate a form from shadcn/ui's FormField to Field?

Replace FormField with React Hook Form's Controller (same props), FormItem with Field invalid={fieldState.invalid}, FormLabel with FieldLabel htmlFor, FormDescription with FieldDescription, and FormMessage with FieldError holding fieldState.error?.message. Drop FormControl and put the id on the control itself. Drop <Form {...form}> unless deep components call useFormContext, in which case use React Hook Form's FormProvider.

Why doesn't my form show any errors?

Check three things. useForm needs resolver: zodResolver(formSchema), or the schema never runs. The submit button needs type="submit": our Button is type="button" by default. And each field needs <FieldError>{fieldState.error?.message}</FieldError>; with the default mode, nothing is checked until the first submit.

How do I turn the input red when it's invalid?

Pass invalid={fieldState.invalid} to Field. It sets aria-invalid on the Base UI control inside, and the control's styles draw the destructive border from it. A native Textarea needs aria-invalid={fieldState.invalid} on it directly, and NativeSelect takes invalid.

Why doesn't my Select keep its value in React Hook Form?

Don't spread field onto Select: pass value={field.value || null} and onValueChange={(next) => field.onChange(next ?? "")}. Base UI's Select uses null for no selection, so the empty string the form starts with has to be mapped to it and back. Pass items so the trigger shows the label rather than the raw value.

How do I validate as the user types instead of on submit?

Set mode on useForm: "onChange" validates on every change, "onBlur" when a field loses focus, and "onTouched" on the first blur and then on every change. After a failed submit, fields re-validate on change anyway (reValidateMode).

How do I show errors that come back from the server?

Return the messages keyed by field name from your API or Server Action, then call form.setError(name, { message }) for each one in onSubmit. They show in the same FieldError as the schema's messages. Use a root. name such as "root.server" for an error that belongs to no field.

Should I use React Hook Form or TanStack Form?

Both work with Field the same way, so pick by API. React Hook Form is what shadcn/ui's older Form was built on, so a form from it moves over by renaming parts; it connects schemas through resolvers. TanStack Form takes a Zod schema directly in validators, lets you choose per event (onChange, onBlur, onSubmit) when each runs, and infers its types from defaultValues.

Does this work with Zod 4?

Yes. The code here uses Zod 4 with @hookform/resolvers 5, whose zodResolver accepts Zod 3 and Zod 4 schemas. In Zod 4, prefer the top-level formats such as z.email(); z.string().email() still works but is deprecated.

Why does the browser show its own message instead of mine?

A required attribute or type="email" makes the browser check the field before React Hook Form does, and it shows its own bubble. Add noValidate to the <form> to leave the checks and the messages to your schema.

How do I add and remove fields at runtime?

Use React Hook Form's useFieldArray: fields to render, append to add, remove(index) to delete. Key each row by item.id, name its field with the index (emails.${index}.address), and validate the list with z.array(), whose .min() and .max() errors land on errors.emails.root.