Prototyper UI

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.

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 zod

Basic 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's Controller because it uses onValueChange instead of onChange.
  • Select + Controller: Same as NumberField — use Controller to bridge onValueChange.
  • invalid prop: Pass invalid={!!errors.fieldName} to TextField or NumberField to 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" };
}
  • 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

On this page