- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Code Block
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- Form
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Trace
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})
export function FormDemo() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
toast("You submitted the following values:", {
description: (
<pre className="mt-1 overflow-x-auto font-mono text-xs">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Card className="w-full sm:max-w-md">
<CardHeader>
<CardTitle>Bug report</CardTitle>
<CardDescription>
Help us improve by reporting bugs you encounter.
</CardDescription>
</CardHeader>
<CardContent>
<form id="form-demo" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-demo-title">Bug title</FieldLabel>
<Input
{...field}
id="form-demo-title"
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-demo-description">
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id="form-demo-description"
placeholder="The login button does nothing when I tap it."
rows={6}
className="min-h-24 resize-none"
aria-invalid={fieldState.invalid}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : (
<FieldDescription>
Include steps to reproduce and what you expected.
</FieldDescription>
)}
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit" form="form-demo">
Submit
</Button>
</CardFooter>
</Card>
)
}Introduction#
shadcn no longer ships a form component: forms are built from Field and the form library of your choice. This guide uses React Hook Form with Zod for validation. Field brings the layout (label, control, then description or error, 6px apart) and the invalid styling, so your form inherits the design without extra classes.
useFormmanages the form state;zodResolvervalidates it against a schema.<Controller />connects each control.<Field />,<FieldLabel />,<FieldDescription />and<FieldError />handle markup and accessibility.
Installation#
Add the components the form uses:
pnpm dlx shadcn@latest add https://ui.ikramhasan.com/r/field.json https://ui.ikramhasan.com/r/input.json https://ui.ikramhasan.com/r/button.json
Add React Hook Form, the resolvers and Zod:
pnpm add react-hook-form @hookform/resolvers zod
Anatomy#
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Bug title</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Form#
Create a form schema#
Define the shape of the form with a Zod schema.
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})Set up the form#
Create the form instance with useForm and the Zod resolver.
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
export function BugReportForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
console.log(data)
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Build the form here */}
</form>
)
}Build the form#
Wire each control with <Controller /> and wrap it in a <Field />. The demo at the top of the page is the complete form:
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})
export function FormDemo() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
toast("You submitted the following values:", {
description: (
<pre className="mt-1 overflow-x-auto font-mono text-xs">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Card className="w-full sm:max-w-md">
<CardHeader>
<CardTitle>Bug report</CardTitle>
<CardDescription>
Help us improve by reporting bugs you encounter.
</CardDescription>
</CardHeader>
<CardContent>
<form id="form-demo" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-demo-title">Bug title</FieldLabel>
<Input
{...field}
id="form-demo-title"
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-demo-description">
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id="form-demo-description"
placeholder="The login button does nothing when I tap it."
rows={6}
className="min-h-24 resize-none"
aria-invalid={fieldState.invalid}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : (
<FieldDescription>
Include steps to reproduce and what you expected.
</FieldDescription>
)}
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit" form="form-demo">
Submit
</Button>
</CardFooter>
</Card>
)
}When you submit, onSubmit receives the validated values. If they are invalid, React Hook Form shows the errors next to each field.
Validation#
Validation modes#
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
})| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit (default). |
"onTouched" | Validation triggers on first blur, then on every change. |
"all" | Validation triggers on blur and change. |
Displaying errors#
Show errors next to the field with <FieldError />:
- Add
data-invalidto the<Field />(or<FieldSet />). - Add
aria-invalidto the control:<Input />,<SelectTrigger />,<Checkbox />,<RadioGroupItem />, …
An invalid field turns its control and message red, never its label. Show the description or the error, not both.
Other controls#
Select, radio, switch and checkbox controls aren't native inputs, so pass field.value and field.onChange instead of spreading field:
| Control | Value prop | Change prop |
|---|---|---|
Select | value | onValueChange |
RadioGroup | value | onValueChange |
Switch | checked | onCheckedChange |
Checkbox | checked | onCheckedChange |
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
const languages = [
{ label: "Select a language", value: null },
{ label: "English", value: "en" },
{ label: "Bangla", value: "bn" },
{ label: "German", value: "de" },
{ label: "Japanese", value: "ja" },
]
const plans = [
{ id: "starter", title: "Starter", description: "For a single store." },
{ id: "pro", title: "Pro", description: "For teams with several stores." },
]
const formSchema = z.object({
language: z.string({ error: "Select your language." }),
plan: z.string({ error: "Choose a plan." }),
marketing: z.boolean(),
terms: z.boolean().refine((value) => value, {
error: "Accept the terms to continue.",
}),
})
type FormValues = z.input<typeof formSchema>
export function FormControls() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
language: undefined,
plan: undefined,
marketing: false,
terms: false,
},
})
function onSubmit(data: FormValues) {
toast("You submitted the following values:", {
description: (
<pre className="mt-1 overflow-x-auto font-mono text-xs">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full max-w-sm flex-col gap-6"
>
<FieldGroup>
<Controller
name="language"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-controls-language">Language</FieldLabel>
<Select
items={languages}
name={field.name}
value={field.value ?? null}
onValueChange={field.onChange}
>
<SelectTrigger
id="form-controls-language"
aria-invalid={fieldState.invalid}
className="w-full"
onBlur={field.onBlur}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{languages.slice(1).map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="plan"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet data-invalid={fieldState.invalid}>
<FieldLegend variant="label">Plan</FieldLegend>
<RadioGroup
name={field.name}
value={field.value ?? ""}
onValueChange={field.onChange}
aria-invalid={fieldState.invalid}
>
{plans.map((plan) => (
<FieldLabel
key={plan.id}
htmlFor={`form-controls-${plan.id}`}
>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{plan.title}</FieldTitle>
<FieldDescription>{plan.description}</FieldDescription>
</FieldContent>
<RadioGroupItem
value={plan.id}
id={`form-controls-${plan.id}`}
aria-invalid={fieldState.invalid}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldSet>
)}
/>
<Controller
name="marketing"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="form-controls-marketing">
Product updates
</FieldLabel>
<FieldDescription>One email a month, at most.</FieldDescription>
</FieldContent>
<Switch
id="form-controls-marketing"
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</Field>
)}
/>
<Controller
name="terms"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<Checkbox
id="form-controls-terms"
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
<FieldContent>
<FieldLabel htmlFor="form-controls-terms">
Accept the terms of service
</FieldLabel>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</FieldContent>
</Field>
)}
/>
</FieldGroup>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit">Save</Button>
</div>
</form>
)
}Resetting the form#
Call form.reset() to return every field to its default value.
<Button type="button" variant="secondary" onClick={() => form.reset()}>
Reset
</Button>