React Aria's Form with consistent spacing, live validation by default and server-side errors mapped onto fields by name. Includes FormSection (fieldset + legend), FormRow for side-by-side fields and FormActions for the button row.
Every field in this library (TextField, Select, DatePicker, Checkbox, …) reads its validation settings from the surrounding Form, so you set them once.
Validation is live by default
React Aria's own default is validationBehavior="native", where the browser
blocks submit and errors appear only after a submit attempt. This Form
defaults to "aria": errors appear as soon as a field is committed (on blur
or change) and nothing blocks onSubmit, so you decide what to do with an
invalid form. Pass validationBehavior="native" to get the browser behavior
back.
Form — any group of inputs that's submitted together: sign-up, checkout, settings, filters.
FormSection — related fields inside a long form (Profile, Billing, Notifications). It's a real <fieldset>, so the title names the group for screen readers.
FormRow — two to four short fields that belong on one line (first and last name; city, state and ZIP).
FormActions — the submit and cancel buttons, aligned and spaced consistently.
A single field that saves on its own (a search box, an inline rename) doesn't need a form.
Fields validate themselves with HTML constraints (isRequired, type="email", minLength, pattern, minValue/maxValue) and with a validate function for custom rules. validate returns an error message, an array of messages, or null when the value is fine. Messages use the browser's localized text unless you return your own. Submitting focuses the first invalid field.
import { Button } from "@/components/ui/button";import { Form, FormActions } from "@/components/ui/form";import { NumberField } from "@/components/ui/number-field";import { TextField } from "@/components/ui/text-field";const reserved = ["admin", "root", "support", "billing"];export default function FormValidation() { return ( <Form className="max-w-sm" onSubmit={(e) => e.preventDefault()}> {/* Built-in constraints: required, type, minLength, pattern. */} <TextField label="Email" name="email" type="email" isRequired placeholder="ana@northwind.io" /> {/* Custom rule: return a message to mark the field invalid. */} <TextField label="Username" name="username" isRequired description="Lowercase letters, numbers and dashes." pattern="[a-z0-9-]+" validate={(value) => reserved.includes(value.toLowerCase()) ? `"${value}" is reserved. Pick another username.` : null } /> <NumberField label="Team size" name="seats" minValue={1} maxValue={50} isRequired description="Plans include up to 50 seats." /> <FormActions align="start"> <Button type="submit">Continue</Button> <Button type="reset" variant="ghost"> Reset </Button> </FormActions> </Form> );}
Pass an object of { [fieldName]: message } to validationErrors. Each field shows the message whose key matches its name and is marked invalid. The error clears as soon as the user edits that field, so they aren't stuck looking at a stale message. Try submitting the defaults, then change one field.
import { useState } from "react";import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";import { Button } from "@/components/ui/button";import { Form, FormActions } from "@/components/ui/form";import { TextField } from "@/components/ui/text-field";type Result = | { ok: true; slug: string } | { ok: false; errors: Record<string, string> };// Stands in for a server action / API route. In a Next.js app this would be// an exported "use server" function that checks the database.async function createWorkspace(data: FormData): Promise<Result> { await new Promise((r) => setTimeout(r, 800)); const slug = String(data.get("slug")).trim().toLowerCase(); const email = String(data.get("billingEmail")).trim().toLowerCase(); const errors: Record<string, string> = {}; if (["acme", "northwind", "globex"].includes(slug)) { errors.slug = `“${slug}” is already taken. Try “${slug}-team”.`; } if (email.endsWith("@example.com")) { errors.billingEmail = "We can't send invoices to example.com addresses."; } return Object.keys(errors).length ? { ok: false, errors } : { ok: true, slug };}export default function FormServerErrors() { const [errors, setErrors] = useState<Record<string, string>>({}); const [created, setCreated] = useState<string | null>(null); const [pending, setPending] = useState(false); return ( <Form className="max-w-sm" validationErrors={errors} onSubmit={async (e) => { e.preventDefault(); setPending(true); setCreated(null); const result = await createWorkspace(new FormData(e.currentTarget)); setPending(false); if (result.ok) { setErrors({}); setCreated(result.slug); } else { setErrors(result.errors); } }} > {created && ( <Alert color="success" showIcon> <AlertTitle>Workspace created</AlertTitle> <AlertDescription> Your workspace lives at app.wrenly.com/{created}. </AlertDescription> </Alert> )} <TextField label="Workspace URL" name="slug" prefix="wrenly.com/" defaultValue="acme" isRequired /> <TextField label="Billing email" name="billingEmail" type="email" defaultValue="finance@example.com" isRequired /> <FormActions> <Button type="submit" isPending={pending}> {pending ? "Creating…" : "Create workspace"} </Button> </FormActions> </Form> );}
With a Next.js server action, return the errors from the action and hand them to the form:
React resets uncontrolled fields after a form action finishes. If you want to keep what the user typed when there are errors, return their values too and pass them as defaultValue, or use onSubmit as in the example above.
Validate the whole form against a schema on submit and feed the resulting field errors to validationErrors. With Zod that's safeParse plus flatten().fieldErrors:
tsx
import { z } from "zod";const shipping = z.object({ name: z.string().min(1, "Enter the recipient's name."), address: z.string().min(5, "That address looks too short."), postcode: z.string().regex(/^\d{5}(-\d{4})?$/, "Use a 5-digit ZIP code, like 94107."), country: z.enum(["US", "CA", "MX"], { message: "Choose a country." }),});function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const result = shipping.safeParse(Object.fromEntries(new FormData(e.currentTarget))); if (!result.success) { setErrors(result.error.flatten().fieldErrors); // { postcode: ["Use a 5-digit…"] } return; } save(result.data); // fully typed}<Form validationErrors={errors} onSubmit={onSubmit}>…</Form>
validationErrors accepts a string or an array of strings per field, so Zod's output fits as-is. Run the same schema on the server to keep both sides in sync. The live example below uses a few lines of hand-written rules in place of Zod, with the same result shape.
import { useState } from "react";import { Button } from "@/components/ui/button";import { Form, FormActions, FormRow } from "@/components/ui/form";import { Select, SelectItem } from "@/components/ui/select";import { TextField } from "@/components/ui/text-field";/* * A tiny schema validator with the same shape as Zod's `safeParse`: * it returns either the typed data or a map of field errors, which goes * straight into <Form validationErrors>. */type Rule = (value: string, all: Record<string, string>) => string | null;type Schema = Record<string, Rule[]>;const required = (message: string): Rule => (v) => v.trim() ? null : message;const minLength = (n: number, message: string): Rule => (v) => v.length >= n ? null : message;const matches = (re: RegExp, message: string): Rule => (v) => re.test(v) ? null : message;function safeParse(schema: Schema, data: FormData) { const values = Object.fromEntries( Object.keys(schema).map((k) => [k, String(data.get(k) ?? "")]), ); const errors: Record<string, string> = {}; for (const [field, rules] of Object.entries(schema)) { for (const rule of rules) { const message = rule(values[field], values); if (message) { errors[field] = message; break; } } } return Object.keys(errors).length ? ({ success: false, errors } as const) : ({ success: true, data: values } as const);}const shippingSchema: Schema = { name: [required("Enter the recipient's name.")], address: [ required("Enter a street address."), minLength(5, "That address looks too short."), ], postcode: [ required("Enter a postcode."), matches(/^\d{5}(-\d{4})?$/, "Use a 5-digit ZIP code, like 94107."), ], country: [required("Choose a country.")],};export default function FormSchema() { const [errors, setErrors] = useState<Record<string, string>>({}); const [saved, setSaved] = useState<Record<string, string> | null>(null); return ( <Form className="max-w-md" validationErrors={errors} onSubmit={(e) => { e.preventDefault(); const result = safeParse(shippingSchema, new FormData(e.currentTarget)); if (result.success) { setErrors({}); setSaved(result.data); } else { setErrors(result.errors); setSaved(null); } }} > <TextField label="Full name" name="name" autoComplete="name" /> <TextField label="Street address" name="address" autoComplete="street-address" /> <FormRow> <TextField label="ZIP code" name="postcode" autoComplete="postal-code" inputMode="numeric" /> <Select label="Country" name="country" placeholder="Select…"> <SelectItem id="US">United States</SelectItem> <SelectItem id="CA">Canada</SelectItem> <SelectItem id="MX">Mexico</SelectItem> </Select> </FormRow> <FormActions align="between"> <span className="text-muted-foreground text-sm" aria-live="polite"> {saved ? `Ships to ${saved.name}, ${saved.postcode}.` : null} </span> <Button type="submit">Save address</Button> </FormActions> </Form> );}
Several FormSections with layout="aside" put each section's title and description in a left column on wide screens, a common layout for settings and account pages. gap="lg" adds room between sections, and FormActions separator closes the form with a hairline.
import { useState } from "react";import { Button } from "@/components/ui/button";import { Form, FormActions, FormRow, FormSection } from "@/components/ui/form";import { Radio, RadioGroup } from "@/components/ui/radio-group";import { Select, SelectItem } from "@/components/ui/select";import { Separator } from "@/components/ui/separator";import { Switch } from "@/components/ui/switch";import { TextField } from "@/components/ui/text-field";import { TextareaField } from "@/components/ui/textarea";export default function FormSettings() { const [savedAt, setSavedAt] = useState<string | null>(null); return ( <Form gap="lg" className="max-w-3xl" onSubmit={(e) => { e.preventDefault(); setSavedAt( new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }), ); }} > <FormSection layout="aside" title="Profile" description="Shown on your comments and in the member directory." > <FormRow> <TextField label="Display name" name="name" defaultValue="Maya Chen" /> <TextField label="Job title" name="title" defaultValue="Product designer" /> </FormRow> <TextareaField label="Bio" name="bio" rows={3} maxLength={160} showCount defaultValue="Designing calm tools for busy teams." /> </FormSection> <Separator /> <FormSection layout="aside" title="Regional" description="Used for dates, times and reminders." > <FormRow> <Select label="Time zone" name="tz" defaultValue="europe-lisbon"> <SelectItem id="america-new_york">New York (GMT−5)</SelectItem> <SelectItem id="europe-lisbon">Lisbon (GMT+0)</SelectItem> <SelectItem id="europe-berlin">Berlin (GMT+1)</SelectItem> <SelectItem id="asia-kathmandu">Kathmandu (GMT+5:45)</SelectItem> </Select> <Select label="Week starts on" name="weekStart" defaultValue="mon"> <SelectItem id="sun">Sunday</SelectItem> <SelectItem id="mon">Monday</SelectItem> </Select> </FormRow> </FormSection> <Separator /> <FormSection layout="aside" title="Notifications" description="Choose what reaches your inbox. Mentions are always on." > <RadioGroup label="Email digest" name="digest" defaultValue="daily"> <Radio value="off">Off</Radio> <Radio value="daily">Daily summary</Radio> <Radio value="weekly">Weekly summary</Radio> </RadioGroup> <Switch name="productUpdates" defaultSelected> Product updates and release notes </Switch> </FormSection> <FormActions separator> {savedAt && ( <span className="mr-auto text-muted-foreground text-sm" role="status"> Saved at {savedAt} </span> )} <Button type="reset" variant="outline"> Discard </Button> <Button type="submit">Save changes</Button> </FormActions> </Form> );}
layout="inline" lines fields and buttons up in a row that wraps, aligned to the bottom edge so a button sits level with the input even when the field has a label. Good for newsletter sign-ups, search bars and table filters. Use aria-label when there's no visible label.
One email a month about new components. No spam.
import { MailIcon } from "lucide-react";import { useState } from "react";import { Button } from "@/components/ui/button";import { Form } from "@/components/ui/form";import { TextField } from "@/components/ui/text-field";export default function FormInline() { const [email, setEmail] = useState<string | null>(null); return ( <div className="flex w-full max-w-md flex-col gap-3"> <Form layout="inline" gap="sm" onSubmit={(e) => { e.preventDefault(); setEmail(String(new FormData(e.currentTarget).get("email"))); }} > <TextField aria-label="Email address" name="email" type="email" placeholder="you@studio.com" prefix={<MailIcon />} isRequired className="min-w-48 flex-1" /> <Button type="submit">Subscribe</Button> </Form> <p className="text-muted-foreground text-xs" aria-live="polite"> {email ? `Thanks! The next issue goes to ${email}.` : "One email a month about new components. No spam."} </p> </div> );}
While a request runs, show isPending on the submit button. It keeps the button focusable, shows a spinner and announces the pending state, and ignores further presses so the form can't be sent twice. Make the fields isReadOnly rather than isDisabled so their values stay readable and are still submitted.
import { useState } from "react";import { Button } from "@/components/ui/button";import { Form, FormActions } from "@/components/ui/form";import { TextField } from "@/components/ui/text-field";import { TextareaField } from "@/components/ui/textarea";export default function FormSubmitting() { const [pending, setPending] = useState(false); const [sent, setSent] = useState(false); return ( <Form className="max-w-sm" onSubmit={async (e) => { e.preventDefault(); setPending(true); await new Promise((r) => setTimeout(r, 1500)); setPending(false); setSent(true); }} > {/* Fields stay readable but can't be edited while the request runs. */} <TextField label="Subject" name="subject" defaultValue="Invoice #2041 shows the wrong VAT number" isRequired isReadOnly={pending} /> <TextareaField label="Message" name="message" rows={4} defaultValue="Hi, our VAT number changed in March. Could you reissue the invoice?" isRequired isReadOnly={pending} /> <FormActions align="between"> <span className="text-muted-foreground text-sm" role="status"> {sent && !pending ? "Sent. We reply within a day." : null} </span> <div className="flex gap-2"> <Button type="button" variant="ghost" isDisabled={pending} onPress={() => setSent(false)} > Cancel </Button> <Button type="submit" isPending={pending}> {pending ? "Sending…" : "Send message"} </Button> </div> </FormActions> </Form> );}
Form renders a native <form>, so Enter in a text field submits it and the browser's autofill works. Give each field a name.
On submit, the first invalid field receives focus, and each field's error is linked to it with aria-describedby and announced.
Server errors from validationErrors mark fields aria-invalid like any other error.
FormSection is a <fieldset> whose <legend> names the group, so screen readers announce "Notifications, group" when focus enters it. The description is linked with aria-describedby.
Status messages after submit ("Saved", "Sent") should live in an element with role="status" or aria-live="polite" so they're announced, as in the examples.
Required fields show an asterisk and set aria-required. Say what the asterisk means near the top of long forms.
The <form>. Also has data-layout="vertical" or "inline".
data-slot="form-section"
The <fieldset>, with data-layout="stacked" or "aside"
data-slot="form-section-title"
The <legend>
data-slot="form-section-description"
The description <p>
data-slot="form-section-content"
The column that holds the fields
data-slot="form-row"
A FormRow grid
data-slot="form-actions"
The button row. Has data-separator when separator is set.
React Aria sets data-invalid on each field, not on the form. To style the whole form while it has errors, use has-data-invalid: on the form's className.