Shadcn Form
Free shadcn/ui form component for React — built on react-hook-form + zod. Type-safe validation, accessible labels and error messages, and worked examples: login, sign-up, contact, checkboxes, switches, radio, date picker, and settings.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
Examples
Username (canonical)
The canonical react-hook-form + zod form — a single validated Input with label, description, and error message.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})
export default function FormDemo01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("You submitted the following values", {
description: (
<pre className="mt-2 w-full max-w-[320px] overflow-x-auto rounded-md bg-neutral-950 p-4">
<code className="text-white">{JSON.stringify(values, null, 2)}</code>
</pre>
),
})
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Login form
Email + password with zod validation (valid email, min-length password) and a full-width submit.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
email: z.string().email({ message: "Enter a valid email address." }),
password: z.string().min(8, {
message: "Password must be at least 8 characters.",
}),
})
export default function FormLogin01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Signed in", { description: values.email })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Sign-up form
Cross-field validation — a zod `.refine` confirms password and confirmation match, plus a required terms checkbox.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z
.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
email: z.string().email({ message: "Enter a valid email address." }),
password: z.string().min(8, {
message: "Password must be at least 8 characters.",
}),
confirm: z.string(),
terms: z.boolean().refine((v) => v === true, {
message: "You must accept the terms.",
}),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords do not match.",
path: ["confirm"],
})
export default function FormSignup01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirm: "",
terms: false,
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Account created", { description: values.email })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Ada Lovelace" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="terms"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="grid gap-1.5 leading-none">
<FormLabel className="font-normal">
Accept terms and conditions
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Create account
</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Contact form
Mixed field types — Input, a Select for the subject, and a Textarea message, each validated.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Toaster } from "@/components/ui/sonner"
import { Textarea } from "@/components/ui/textarea"
const formSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
email: z.string().email({ message: "Enter a valid email address." }),
subject: z.string().min(1, { message: "Please choose a subject." }),
message: z.string().min(10, {
message: "Message must be at least 10 characters.",
}),
})
export default function FormContact01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: "", email: "", subject: "", message: "" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Message sent", { description: `Subject: ${values.subject}` })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Ada Lovelace" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a topic" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="sales">Sales</SelectItem>
<SelectItem value="support">Support</SelectItem>
<SelectItem value="billing">Billing</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder="How can we help?"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Send message
</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Checkbox preferences
Boolean checkbox fields, with a required terms checkbox validated by a zod `.refine`.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
marketing: z.boolean(),
security: z.boolean(),
terms: z.boolean().refine((v) => v === true, {
message: "You must accept the terms to continue.",
}),
})
export default function FormCheckbox01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { marketing: true, security: true, terms: false },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Preferences saved", {
description: `Marketing: ${values.marketing ? "on" : "off"}`,
})
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="marketing"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="grid gap-1.5 leading-none">
<FormLabel className="font-normal">Marketing emails</FormLabel>
<FormDescription>
News about products and features.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="grid gap-1.5 leading-none">
<FormLabel className="font-normal">Security alerts</FormLabel>
<FormDescription>
Important notices about your account.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terms"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="grid gap-1.5 leading-none">
<FormLabel className="font-normal">
Accept terms and conditions
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit">Save preferences</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Notification switches
Bordered rows pairing a label and description with a Switch, bound to boolean fields.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form"
import { Switch } from "@/components/ui/switch"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
marketing: z.boolean(),
social: z.boolean(),
security: z.boolean(),
})
const fields = [
{
name: "marketing" as const,
label: "Marketing emails",
description: "Receive emails about new products and features.",
},
{
name: "social" as const,
label: "Social notifications",
description: "Receive emails for follows, likes, and comments.",
},
{
name: "security" as const,
label: "Security emails",
description: "Receive emails about your account activity.",
},
]
export default function FormSwitch01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { marketing: true, social: false, security: true },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Notifications updated", {
description: `Security: ${values.security ? "on" : "off"}`,
})
}
return (
<div className="w-full max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.map((f) => (
<FormField
key={f.name}
control={form.control}
name={f.name}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>{f.label}</FormLabel>
<FormDescription>{f.description}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
<Button type="submit">Save</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Radio group
A single-choice RadioGroup bound to a zod enum, with nested FormItems per option.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
type: z.enum(["all", "mentions", "none"]),
})
const options = [
{ value: "all", label: "All new messages" },
{ value: "mentions", label: "Direct messages and mentions" },
{ value: "none", label: "Nothing" },
]
export default function FormRadio01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { type: "all" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Notification preference saved", { description: values.type })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Notify me about…</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-1"
>
{options.map((option) => (
<FormItem
key={option.value}
className="flex items-center gap-3 space-y-0"
>
<FormControl>
<RadioGroupItem value={option.value} />
</FormControl>
<FormLabel className="font-normal">
{option.label}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Date of birth
A Popover + Calendar bound to a zod date, with future and pre-1900 dates disabled.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
dob: z.date({ error: "A date of birth is required." }),
})
export default function FormDate01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Date of birth saved", {
description: format(values.dob, "PPP"),
})
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="dob"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Date of birth</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto size-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
<FormDescription>
Your date of birth is used to calculate your age.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Textarea with counter
A bio Textarea with a live character counter and zod min/max length validation.
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Textarea } from "@/components/ui/textarea"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
bio: z
.string()
.min(10, { message: "Bio must be at least 10 characters." })
.max(160, { message: "Bio must be 160 characters or less." }),
})
export default function FormTextarea01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { bio: "" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Bio saved", { description: `${values.bio.length} characters` })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
{field.value.length}/160 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
<Toaster />
</div>
)
}
Account settings
A multi-field settings form — username (Input), verified email (Select), and bio (Textarea).
Packages
Props
No props documented yet.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Toaster } from "@/components/ui/sonner"
const formSchema = z.object({
username: z
.string()
.min(2, { message: "Username must be at least 2 characters." })
.max(30, { message: "Username must be 30 characters or less." }),
email: z.string().min(1, { message: "Please select a verified email." }),
bio: z.string().max(160, { message: "Bio must be 160 characters or less." }),
})
export default function FormSettings01() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { username: "", email: "", bio: "" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
toast("Profile updated", { description: `@${values.username}` })
}
return (
<div className="w-full max-w-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a verified email" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="[email protected]">
[email protected]
</SelectItem>
<SelectItem value="[email protected]">
[email protected]
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Manage verified emails in your settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update profile</Button>
</form>
</Form>
<Toaster />
</div>
)
}
The Shadcn Form is the bridge between react-hook-form and your shadcn inputs. It is a handful of composable parts that wire labels, controls, descriptions, and validation errors together — accessibly and type-safely — so a fully validated form is mostly schema and composition. The examples below are complete, copy-paste forms: login, sign-up, contact, checkboxes, switches, radio, a date picker, and settings.
Built on react-hook-form + zod
The Form is not an input — it is structure. Three libraries do the work:
npm install react-hook-form zod @hookform/resolvers
- react-hook-form manages field state, submission, and validation with minimal re-renders.
- zod declares your schema — the rules and the TypeScript types.
- @hookform/resolvers connects the two via
zodResolver.
Anatomy
Seven parts compose every field:
Form— react-hook-form'sFormProvider; wrap your<form>in it.FormField— binds one field bynametoform.controland exposes afieldrender prop.FormItem— groups one field's label, control, description, and message.FormLabel— a label wired to the control (turns red on error).FormControl— aSlotthat putsid,aria-invalid, andaria-describedbyonto your input.FormDescription— helper text.FormMessage— renders the field's validation error automatically.
A complete example
Define the schema, wire the resolver, compose the fields:
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})
export function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { username: "" },
})
function onSubmit(values: z.infer<typeof formSchema>) {
// values is fully typed and validated
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
There is no manual error handling — when username fails the schema, FormMessage shows the message and FormLabel turns red.
Validation with zod
The schema is the single source of truth for both rules and types:
- Per-field rules carry their own message:
z.string().email({ message: "…" }),.min(8, { message: "…" }),.max(160). - Booleans that must be true (accept-terms):
z.boolean().refine((v) => v === true, { message: "…" }). - Enums (radio / select):
z.enum(["all", "mentions", "none"]). - Cross-field rules use
.refineon the object and target a field withpath:
const schema = z
.object({ password: z.string().min(8), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "Passwords do not match.",
path: ["confirm"],
})
The Sign-up example uses exactly this for password confirmation.
Binding each field type
Inputs and textareas spread {...field}. Controlled components map their own props:
| Control | Binding |
|---|---|
| Input / Textarea | {...field} |
| Checkbox / Switch | checked={field.value} onCheckedChange={field.onChange} |
| Select | value={field.value} onValueChange={field.onChange} |
| RadioGroup | value={field.value} onValueChange={field.onChange} |
| Calendar (in a Popover) | selected={field.value} onSelect={field.onChange} |
The Contact, Checkbox, Switch, Radio, and Date examples each show one in a real form.
Submitting
form.handleSubmit(onSubmit) runs validation first and only calls your handler with typed, valid values. From there you can call a Next.js server action or a route handler — and re-validate with the same zod schema on the server, since client validation is for UX and server validation is for trust.
Accessibility
The wiring is automatic: FormControl links the input to its FormDescription and FormMessage through aria-describedby, sets aria-invalid when the field errors, and FormLabel is associated via htmlFor and turns red on error. Errors are therefore both visible and announced with no extra markup.