Forms
Build accessible forms with validation using FormField, Field, TextField, and react-hook-form.
Prototyper UI provides a composable form system built on Base UI's Field primitive. For react-hook-form users, the FormField helper reduces boilerplate to a single component per field.
Recommended: FormField
The fastest way to build validated forms. FormField wires Controller, Field, FieldLabel, FieldDescription, and FieldError into one component:
"use client";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
});
type FormValues = z.infer<typeof schema>;
export function ContactForm() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "" },
});
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
<FormField name="name" label="Name" required>
<Input placeholder="Jane Doe" />
</FormField>
<FormField name="email" label="Email" required>
<Input type="email" placeholder="jane@example.com" />
</FormField>
<Button type="submit">Submit</Button>
</form>
</FormProvider>
);
}See the FormField docs for validation, dynamic fields, and multi-step examples.
Advanced: Raw Field components
For full control or components with non-standard change handlers (Select, NumberField, Checkbox, Switch, RadioGroup), use Field directly with react-hook-form's Controller.
Basic form
A simple form using TextField with Field components for structure:
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldDescription } from "@/components/ui/field";
import { TextField, Input } from "@/components/ui/text-field";
export function ContactForm() {
return (
<form className="space-y-4">
<TextField>
<FieldLabel>Name</FieldLabel>
<Input placeholder="Jane Doe" />
</TextField>
<TextField>
<FieldLabel>Email</FieldLabel>
<Input type="email" placeholder="jane@example.com" />
<FieldDescription>We'll never share your email.</FieldDescription>
</TextField>
<Button type="submit">Submit</Button>
</form>
);
}Field components
Field
The Field wrapper connects labels, inputs, descriptions, and errors. It renders a role="group" container with data-slot="field".
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
} from "@/components/ui/field";
<Field>
<FieldLabel>Username</FieldLabel>
{/* your input here */}
<FieldDescription>Choose a unique username.</FieldDescription>
<FieldError>Username is already taken.</FieldError>
</Field>;TextField
Combines Base UI's Field.Root with an Input or TextArea. Provides built-in validation binding.
import { TextField, Input, TextArea } from "@/components/ui/text-field";
{
/* Single-line input */
}
<TextField>
<FieldLabel>Name</FieldLabel>
<Input />
</TextField>;
{
/* Multi-line textarea */
}
<TextField>
<FieldLabel>Bio</FieldLabel>
<TextArea rows={4} />
</TextField>;NumberField
A numeric input with increment/decrement buttons and keyboard support.
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
NumberFieldSteppers,
} from "@/components/ui/number-field";
<NumberField defaultValue={5} min={0} max={100}>
<FieldLabel>Quantity</FieldLabel>
<NumberFieldGroup>
<NumberFieldInput />
<NumberFieldSteppers />
</NumberFieldGroup>
</NumberField>;Select
A dropdown select with search, grouping, and keyboard navigation.
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
<Select>
<FieldLabel>Role</FieldLabel>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>;FieldSet and FieldLegend
Group related fields together with FieldSet for semantic form structure:
import { FieldSet, FieldLegend } from "@/components/ui/field";
<FieldSet>
<FieldLegend>Personal Information</FieldLegend>
<TextField>
<FieldLabel>First name</FieldLabel>
<Input />
</TextField>
<TextField>
<FieldLabel>Last name</FieldLabel>
<Input />
</TextField>
</FieldSet>;Horizontal layout
Use the orientation prop on Field for side-by-side label and input layouts:
<Field orientation="horizontal">
<FieldLabel>Email</FieldLabel>
<Input type="email" />
</Field>Available orientations: "vertical" (default), "horizontal", and "responsive" (vertical on small screens, horizontal on larger ones).
Required fields
Mark a field as required using the required prop on FieldLabel:
<TextField required>
<FieldLabel required>Email</FieldLabel>
<Input type="email" />
</TextField>This adds a red asterisk (*) after the label text.
Showing errors
Use FieldError to display validation messages. The error animates in with a height transition:
<TextField invalid>
<FieldLabel>Email</FieldLabel>
<Input type="email" />
<FieldError>Please enter a valid email address.</FieldError>
</TextField>FieldError also accepts an errors array for showing multiple messages:
<FieldError
errors={[
{ message: "Must be at least 8 characters" },
{ message: "Must contain a number" },
]}
/>react-hook-form integration
Setup
Install react-hook-form and zod:
npm install react-hook-form @hookform/resolvers zodBasic 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 { Field, FieldLabel, FieldError } from "@/components/ui/field";
import { TextField, Input } from "@/components/ui/text-field";
const schema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Must be at least 8 characters"),
});
type FormValues = z.infer<typeof schema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<TextField invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input type="email" {...register("email")} />
<FieldError>{errors.email?.message}</FieldError>
</TextField>
<TextField invalid={!!errors.password}>
<FieldLabel>Password</FieldLabel>
<Input type="password" {...register("password")} />
<FieldError>{errors.password?.message}</FieldError>
</TextField>
<Button type="submit">Log in</Button>
</form>
);
}With all field types
"use client";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldSet,
FieldLegend,
} from "@/components/ui/field";
import { TextField, Input, TextArea } from "@/components/ui/text-field";
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
NumberFieldSteppers,
} from "@/components/ui/number-field";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
age: z.number().min(18, "Must be at least 18").max(120),
role: z.string().min(1, "Please select a role"),
bio: z.string().max(500, "Bio must be under 500 characters").optional(),
});
type FormValues = z.infer<typeof schema>;
export function RegistrationForm() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormValues) => {
await fetch("/api/register", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FieldSet>
<FieldLegend>Account Details</FieldLegend>
<TextField invalid={!!errors.name}>
<FieldLabel required>Name</FieldLabel>
<Input {...register("name")} />
<FieldError>{errors.name?.message}</FieldError>
</TextField>
<TextField invalid={!!errors.email}>
<FieldLabel required>Email</FieldLabel>
<Input type="email" {...register("email")} />
<FieldError>{errors.email?.message}</FieldError>
</TextField>
</FieldSet>
<FieldSet>
<FieldLegend>Profile</FieldLegend>
<Controller
name="age"
control={control}
render={({ field }) => (
<NumberField
value={field.value}
onValueChange={field.onChange}
min={0}
max={120}
invalid={!!errors.age}
>
<FieldLabel required>Age</FieldLabel>
<NumberFieldGroup>
<NumberFieldInput />
<NumberFieldSteppers />
</NumberFieldGroup>
<FieldError>{errors.age?.message}</FieldError>
</NumberField>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Field data-invalid={!!errors.role || undefined}>
<FieldLabel required>Role</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="designer">Designer</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
</SelectContent>
</Select>
<FieldError>{errors.role?.message}</FieldError>
</Field>
)}
/>
<TextField invalid={!!errors.bio}>
<FieldLabel>Bio</FieldLabel>
<TextArea rows={4} {...register("bio")} />
<FieldDescription>
Optional — tell us about yourself.
</FieldDescription>
<FieldError>{errors.bio?.message}</FieldError>
</TextField>
</FieldSet>
<Button type="submit" isPending={isSubmitting}>
Create account
</Button>
</form>
);
}Key patterns
- TextField + register: For text and textarea inputs, spread
{...register("fieldName")}directly on<Input>or<TextArea>. - NumberField + Controller: For
NumberField, use react-hook-form'sControllerbecause it usesonValueChangeinstead ofonChange. - Select + Controller: Same as NumberField — use
Controllerto bridgeonValueChange. - invalid prop: Pass
invalid={!!errors.fieldName}toTextFieldorNumberFieldto trigger error styling on the input. - FieldError: Render inside the field wrapper. It auto-animates with a height transition when content appears.
Server-side validation
For server actions in Next.js, you can handle validation on the server and return errors to display in FieldError:
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import { TextField, Input } from "@/components/ui/text-field";
interface FormState {
errors?: {
email?: string[];
password?: string[];
};
message?: string;
}
export function ServerForm({
action,
}: {
action: (state: FormState, formData: FormData) => Promise<FormState>;
}) {
const [state, formAction, isPending] = useActionState(action, {});
return (
<form action={formAction} className="space-y-4">
<TextField invalid={!!state.errors?.email}>
<FieldLabel>Email</FieldLabel>
<Input type="email" name="email" />
<FieldError>{state.errors?.email?.[0]}</FieldError>
</TextField>
<TextField invalid={!!state.errors?.password}>
<FieldLabel>Password</FieldLabel>
<Input type="password" name="password" />
<FieldError>{state.errors?.password?.[0]}</FieldError>
</TextField>
{state.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" isPending={isPending}>
Submit
</Button>
</form>
);
}And the corresponding server action:
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Must be at least 8 characters"),
});
export async function loginAction(state: FormState, formData: FormData) {
const result = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
// Authenticate user...
return { message: "Success" };
}Related components
- Field — Labels, descriptions, errors, and layout
- TextField — Text and textarea inputs with Base UI validation
- NumberField — Numeric input with increment/decrement
- Select — Dropdown selection
- Checkbox — Boolean toggle
- RadioGroup — Single selection from a group
- Switch — Toggle switch