Forms
Form.
Form building blocks wired to react-hook-form, with zod validation, accessible labels, descriptions and error messages.
"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 { Textarea } from "@/components/ui/textarea";
const formSchema = z.object({
name: z.string().min(3, "Site name needs at least 3 characters.").max(32, "Keep the site name under 32 characters."),
address: z
.string()
.min(3, "Address needs at least 3 characters.")
.regex(/^[a-z0-9-]+$/, "Use lowercase letters, numbers and dashes only."),
about: z.string().max(140, "Keep the description under 140 characters.").optional(),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { name: "", address: "", about: "" },
});
function onSubmit(values: FormValues) {
toast("Site published", {
description: (
<pre className="mt-2 w-full overflow-x-auto rounded-md border bg-background p-3 font-mono text-[11px] text-foreground">
{JSON.stringify(values, null, 2)}
</pre>
),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} noValidate className="grid w-full max-w-sm gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Site name</FormLabel>
<FormControl>
<Input placeholder="Turtle Farm" {...field} />
</FormControl>
<FormDescription>Shown in the hub directory.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">hub://</span>
<FormControl>
<Input placeholder="turtle-farm" className="font-mono" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="about"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="What visitors will find here." className="min-h-20" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-3">
<Button type="submit">Publish site</Button>
<Button type="button" variant="ghost" onClick={() => form.reset()}>
Reset
</Button>
</div>
</form>
</Form>
);
}Installation#
With the CLI
$npx lanterncn add formThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/form. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install react-hook-form zod @hookform/resolvers radix-uiCopy the source into your project
components/ui/form.tsx"use client"; import * as React from "react"; import type { Label as LabelPrimitive } from "radix-ui"; import { Slot } from "radix-ui"; 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.5", 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.Root>) { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); return ( <Slot.Root 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-[13px] leading-normal text-muted-foreground", 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-[13px] text-destructive", className)} {...props} > {body} </p> ); } export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand uses label.
Usage#
import {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} from "@/components/ui/form";const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Site name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Shown in the hub directory.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>FormControl passes the id, aria-describedby and aria-invalid to its child, so FormLabel, FormDescription and FormMessage are linked to the control automatically.
Field is the lighter option: the same layout without react-hook-form, for forms you validate yourself.
Examples#
Select, switch and checkbox
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
world: z.string({ error: "Pick a world for the server." }).min(1, "Pick a world for the server."),
whitelist: z.boolean(),
rules: z.boolean().refine((value) => value, { message: "Accept the hub rules to continue." }),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormControls() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { world: "", whitelist: true, rules: false },
});
function onSubmit(values: FormValues) {
toast.success("Server settings saved", {
description: `${values.world}, whitelist ${values.whitelist ? "on" : "off"}.`,
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} noValidate className="grid w-full max-w-sm gap-6">
<FormField
control={form.control}
name="world"
render={({ field }) => (
<FormItem>
<FormLabel>World</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-full" onBlur={field.onBlur}>
<SelectValue placeholder="Pick a world" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Overworld">Overworld</SelectItem>
<SelectItem value="Deepstone">Deepstone</SelectItem>
<SelectItem value="Skylands">Skylands</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelist"
render={({ field }) => (
<FormItem className="flex items-center justify-between gap-4 rounded-md border border-input p-4">
<div className="grid gap-1.5">
<FormLabel>Whitelist</FormLabel>
<FormDescription>Only listed players can join.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="rules"
render={({ field }) => (
<FormItem className="gap-2">
<div className="flex items-center gap-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={(checked) => field.onChange(checked === true)} />
</FormControl>
<FormLabel>I accept the hub rules</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full sm:w-fit">
Save settings
</Button>
</form>
</Form>
);
}