Forms
Layout, validation, errors and submission: the rules that make forms fast to complete and hard to get wrong.
Forms are where products ask people for effort. Every rule below exists to reduce that effort: fewer decisions, clearer errors, no lost work.
"use client"
import * as React from "react"
import { CircleAlertIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
type Errors = Partial<Record<"name" | "email" | "password" | "terms", string>>
function validate(data: FormData): Errors {
const errors: Errors = {}
const email = String(data.get("email") ?? "")
const password = String(data.get("password") ?? "")
if (!String(data.get("name") ?? "").trim()) errors.name = "Enter your name."
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.email = "Enter an email address like ada@acme.co."
if (password.length < 10) errors.password = "Use at least 10 characters."
if (!data.get("terms"))
errors.terms = "Accept the terms to create an account."
return errors
}
export default function SignupForm() {
const [errors, setErrors] = React.useState<Errors>({})
const [submitted, setSubmitted] = React.useState(false)
const [pending, setPending] = React.useState(false)
const summary = React.useRef<HTMLDivElement>(null)
function onChange(event: React.FormEvent<HTMLFormElement>) {
// After a failed submit, an error clears the moment its field is fixed.
// New errors wait for the next submit: one appearing or vanishing on
// blur would move the button out from under a click.
if (!submitted) return
const next = validate(new FormData(event.currentTarget))
setErrors((current) => {
const kept: Errors = {}
for (const key of Object.keys(current) as (keyof Errors)[]) {
if (next[key]) kept[key] = next[key]
}
return kept
})
}
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitted(true)
const next = validate(new FormData(event.currentTarget))
setErrors(next)
if (Object.keys(next).length > 0) {
requestAnimationFrame(() => summary.current?.focus())
return
}
setPending(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
setPending(false)
toast.add({
title: "Account created",
description: "Check your inbox to verify your email.",
type: "success",
})
}
const count = Object.keys(errors).length
return (
<form
noValidate
onSubmit={onSubmit}
onChange={onChange}
className="w-full max-w-sm"
>
<FieldGroup>
{count > 0 && (
<div ref={summary} tabIndex={-1} className="outline-none">
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>
{count === 1
? "1 field needs attention"
: `${count} fields need attention`}
</AlertTitle>
<AlertDescription>
Fix the highlighted fields and try again.
</AlertDescription>
</Alert>
</div>
)}
<Field data-invalid={errors.name ? true : undefined}>
<FieldLabel htmlFor="signup-name">Full name</FieldLabel>
<Input
id="signup-name"
name="name"
autoComplete="name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "signup-name-error" : undefined}
/>
<FieldError id="signup-name-error">{errors.name}</FieldError>
</Field>
<Field data-invalid={errors.email ? true : undefined}>
<FieldLabel htmlFor="signup-email">Work email</FieldLabel>
<Input
id="signup-email"
name="email"
type="email"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "signup-email-error" : undefined}
/>
<FieldError id="signup-email-error">{errors.email}</FieldError>
</Field>
<Field data-invalid={errors.password ? true : undefined}>
<FieldLabel htmlFor="signup-password">Password</FieldLabel>
<Input
id="signup-password"
name="password"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby="signup-password-help"
/>
{/* The error takes the hint's place and its id, so the input is
always described by whichever of the two is shown. */}
{errors.password ? (
<FieldError id="signup-password-help">{errors.password}</FieldError>
) : (
<FieldDescription id="signup-password-help">
At least 10 characters.
</FieldDescription>
)}
</Field>
<Field
orientation="horizontal"
data-invalid={errors.terms ? true : undefined}
>
<Checkbox
id="signup-terms"
name="terms"
aria-invalid={!!errors.terms}
aria-describedby={errors.terms ? "signup-terms-error" : undefined}
/>
<FieldLabel htmlFor="signup-terms" className="font-normal">
I agree to the terms of service
</FieldLabel>
</Field>
{errors.terms && (
<FieldError id="signup-terms-error" className="-mt-3">
{errors.terms}
</FieldError>
)}
<Button type="submit" loading={pending} className="w-full">
Create account
</Button>
</FieldGroup>
</form>
)
}Layout
- One column. Multi-column forms break the reading path and hide fields. The exception is short, related pairs — first and last name, expiry and CVC.
- Labels above fields. Top-aligned labels are the fastest to scan and survive translation and zoom. Use
FieldLabel, never a placeholder, as the label. - Group with
FieldSet. Related fields share aFieldLegend; groups are separated by more space than fields within them. - Size the field to the answer. A postcode field shouldn't be as wide as an address field.
- Horizontal rows for settings. Settings pages pair a label and description on the left with the control on the right, using
Field orientation="responsive"so it stacks on narrow screens.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
const regions = [
{ label: "Frankfurt (eu-central-1)", value: "fra" },
{ label: "Warsaw (eu-central-2)", value: "waw" },
{ label: "Virginia (us-east-1)", value: "iad" },
]
export default function SettingsForm() {
const [saving, setSaving] = React.useState(false)
async function save(event: React.FormEvent) {
event.preventDefault()
setSaving(true)
await new Promise((resolve) => setTimeout(resolve, 900))
setSaving(false)
toast.add({ title: "Settings saved", type: "success" })
}
return (
<form onSubmit={save} className="w-full max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Project settings</CardTitle>
<CardDescription>Changes apply to new deployments.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-name">Project name</FieldLabel>
<FieldDescription>
Used in URLs and notifications.
</FieldDescription>
</FieldContent>
<Input
id="project-name"
defaultValue="acme-web"
className="sm:max-w-64"
/>
</Field>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-region">Region</FieldLabel>
<FieldDescription>
Where functions run by default.
</FieldDescription>
</FieldContent>
<Select items={regions} defaultValue="fra">
<SelectTrigger
id="project-region"
className="w-full sm:max-w-64"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{regions.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<FieldSeparator />
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="preview-deploys">
Preview deployments
</FieldLabel>
<FieldDescription>
Deploy every pull request to a unique URL.
</FieldDescription>
</FieldContent>
<Switch id="preview-deploys" defaultChecked />
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="protect">Password protection</FieldLabel>
<FieldDescription>
Require a password to view previews.
</FieldDescription>
</FieldContent>
<Switch id="protect" />
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<Button type="submit" loading={saving}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
)
}Required and optional
Mark the minority. Most forms should only ask for what's required — then label the few optional fields with “(optional)” and don't decorate required ones. When most fields are optional, mark the required ones instead. Never rely on an asterisk alone; it means nothing to many people and to screen readers.
Validation
When to validate
- On submit, validate everything and move focus to the first invalid field, or to an error summary at the top on forms longer than one screen.
- After the first submit, re-check fields as people type, and clear an error the moment its field is fixed. New errors wait for the next submit. Don't validate on
blur: a message that appears or disappears as focus leaves a field moves the submit button out from under the click. - Never on every keystroke before a submit — being told an email is invalid after typing one letter is hostile.
How to show errors
- Put the message directly under the field with
FieldError, which renders an icon and text — never color alone. - Mark the control with
aria-invalidand the field withdata-invalid. - Say what's wrong and how to fix it: Enter an email address like ada@acme.co, not Invalid input.
- For forms longer than one screen, add an
Alertsummary that lists the problems and receives focus.
<Field data-invalid>
<FieldLabel htmlFor="email">Work email</FieldLabel>
<Input id="email" aria-invalid />
<FieldError>Enter an email address like ada@acme.co.</FieldError>
</Field>Submission
- Keep the submit button enabled. A disabled button can't explain what's missing; submitting and showing errors can. The one exception is a typed confirmation (see Destructive actions): there the typed name is the answer, and the button waits for it.
- Show progress on the button with
loading. It keeps its width and focus, and blocks double submission. - Confirm success with a toast when people stay on the page, or navigate to the result.
- Never clear the form on error. Server errors are shown inline or in a summary, and every value is preserved.
Add a domain ending, like .com.
Accessibility checklist
- Every control has a programmatic label (
htmlFor+id, oraria-labelfor icon-only controls). - Help text is linked with
aria-describedby; errors are announced (FieldErrorusesrole="alert"). autocompleteattributes are set for personal data (name,email,new-password), so browsers and password managers can help.- The form is fully operable with the keyboard, in a logical order, with visible focus.