Skip to content

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 a FieldLegend; 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.
Project settings
"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

  1. 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.
  2. 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.
  3. 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-invalid and the field with data-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 Alert summary 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.
DoSpecific guidance next to the field, with an icon.
Don’tA red border alone fails people who can't see the color — and explains nothing.

Accessibility checklist

  • Every control has a programmatic label (htmlFor + id, or aria-label for icon-only controls).
  • Help text is linked with aria-describedby; errors are announced (FieldError uses role="alert").
  • autocomplete attributes 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.