shadcn Form
Build forms with shadcn's Form component — react-hook-form + Zod + accessible UI components, all wired together.
Recipe
Quick-reference recipe card — copy-paste ready.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const Schema = z.object({
username: z.string().min(2, "At least 2 characters"),
email: z.string().email("Invalid email"),
});
type FormData = z.infer<typeof Schema>;
export function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { username: "", email: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => console.log(data))} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}When to reach for this: When you use shadcn/ui and want consistent, accessible form fields with built-in error display, labels, and descriptions.
Working Example
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const FeedbackSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
category: z.enum(["bug", "feature", "question", "other"], {
required_error: "Select a category",
}),
message: z.string().min(10, "At least 10 characters").max(1000),
priority: z.enum(["low", "medium", "high"]).default("medium"),
subscribe: z.boolean().default(false),
});
type FeedbackData = z.infer<typeof FeedbackSchema>;
export function FeedbackForm() {
const form = useForm<FeedbackData>({
resolver: zodResolver(FeedbackSchema),
defaultValues: {
name: "",
email: "",
message: "",
priority: "medium",
subscribe: false,
},
});
async function onSubmit(data: FeedbackData) {
await new Promise((r) => setTimeout(r, 1000));
console.log("Feedback:", data);
form.reset();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bug">Bug Report</SelectItem>
<SelectItem value="feature">Feature Request</SelectItem>
<SelectItem value="question">Question</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea placeholder="Describe your feedback..." rows={4} {...field} />
</FormControl>
<FormDescription>Minimum 10 characters.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscribe"
render={({ field }) => (
<FormItem className="flex items-start space-x-3 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Email updates</FormLabel>
<FormDescription>Get notified about the status of your feedback.</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.isSubmitting ? "Sending..." : "Send Feedback"}
</Button>
</form>
</Form>
);
}What this demonstrates:
- shadcn Form with multiple input types (Input, Textarea, Select, Checkbox)
- Grid layout for side-by-side fields
- FormDescription for helper text
- FormMessage auto-displays Zod validation errors
- Select integration via
onValueChange/defaultValue
Deep Dive
How It Works
<Form>is a context provider that passes theuseFormreturn value to all children<FormField>is a wrapper around RHF's<Controller>— it providesfield,fieldState, andformState<FormItem>establishes a context with a generatedidfor label-input association<FormLabel>renders a<label>with the correcthtmlForand error styling<FormControl>passesaria-invalid,aria-describedby, and theidto the input<FormMessage>reads the error from the field context and renders it withrole="alert"<FormDescription>renders helper text linked viaaria-describedby
Variations
Radio group:
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem>
<FormLabel>Plan</FormLabel>
<FormControl>
<RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="flex gap-4">
<FormItem className="flex items-center space-x-2 space-y-0">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel className="font-normal">Free</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2 space-y-0">
<FormControl><RadioGroupItem value="pro" /></FormControl>
<FormLabel className="font-normal">Pro</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>Date picker:
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline">
{field.value ? format(field.value, "PPP") : "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" selected={field.value} onSelect={field.onChange} />
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>TypeScript Notes
// FormField is generic — name is type-checked against the form schema
<FormField
control={form.control}
name="typo" // TS error: "typo" is not in FeedbackData
render={({ field }) => /* ... */}
/>
// field.value is typed per field
<FormField
name="category"
render={({ field }) => {
field.value; // "bug" | "feature" | "question" | "other"
}}
/>Gotchas
-
Select needs
onValueChange, notonChange— Radix Select does not use native change events. Fix: Wirefield.onChangetoonValueChangeandfield.valuetodefaultValue. -
Checkbox returns
boolean, not string — Usefield.value(boolean) withchecked, andfield.onChangewithonCheckedChange. Do not use{...field}spread directly. -
FormControl must wrap exactly one input — It clones
aria-*andidprops onto its single child. Wrapping multiple elements breaks the association. Fix: Wrap only the input element. -
Missing defaultValues — If
defaultValuesis incomplete, shadcn Select shows a blank trigger instead of the placeholder. Fix: Provide completedefaultValuesor usedefaultValueon Select.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Plain RHF + register | You use native HTML inputs without a component library | You use shadcn/ui components |
| Formik + MUI | You are in a Material UI codebase | You use Tailwind / shadcn |
| Mantine form | You use the Mantine component library | You use shadcn |
| Custom form primitives | You need a fully custom design system | shadcn covers your needs |
FAQs
What does the shadcn Form component do under the hood?
<Form> is a context provider that passes the useForm return value to all children. Child components like <FormField>, <FormLabel>, <FormControl>, and <FormMessage> read from this context to wire up IDs, aria attributes, and error display.
How does FormField relate to react-hook-form's Controller?
<FormField> is a wrapper around RHF's <Controller>. Its render prop provides field, fieldState, and formState, just like Controller does.
What accessibility features does FormControl provide automatically?
- Sets a generated
idon the input for label association - Adds
aria-invalidwhen the field has an error - Adds
aria-describedbylinking to the description and error message elements
How do you wire a shadcn Select component inside FormField?
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Pick one" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>Use onValueChange, not onChange, because Radix Select does not use native change events.
How do you use a Checkbox with shadcn Form?
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>Do not spread {...field} directly on Checkbox since it expects checked (boolean), not value.
What is the purpose of FormDescription?
It renders helper text below the input and is automatically linked via aria-describedby so screen readers announce it when the input is focused.
How does FormMessage display validation errors?
<FormMessage> reads the error from the field context (provided by FormField) and renders the error message with role="alert". No manual error checking is needed.
Gotcha: Why does a shadcn Select show a blank trigger instead of the placeholder?
This happens when defaultValues in useForm is incomplete or missing for that field. Provide complete defaultValues for all fields, or set defaultValue directly on the Select component.
Gotcha: What breaks if you wrap multiple elements inside FormControl?
FormControl clones aria-* and id props onto its single child. Wrapping multiple elements breaks the label-input association and accessibility attributes. Always wrap exactly one input element.
How do you add a RadioGroup inside a shadcn Form?
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormItem className="flex items-center space-x-2">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel>Free</FormLabel>
</FormItem>
</RadioGroup>TypeScript: Is the name prop on FormField type-checked?
Yes. FormField is generic and checks name against the form schema type. Passing a name that does not exist in the schema produces a TypeScript error.
TypeScript: Is field.value typed per field inside the render prop?
Yes. For an enum field like category: z.enum(["bug", "feature"]), field.value is typed as "bug" | "feature", not string.
Can you use shadcn Form with a Date Picker?
Yes. Use a Popover with a Calendar inside FormField. Wire field.value to selected and field.onChange to onSelect on the Calendar component.
Related
- RHF + Zod — resolver setup
- React Hook Form — core RHF concepts
- Form Error Display — error patterns
- shadcn Form component — shadcn component details