Form

A collection of form patterns — single-section forms, multi-step wizards, inline validation, and success states. Forms are the primary input surface in product UIs; these patterns cover the common cases.

Single-page form

Basic info
Visibility

Only you can see this project

All team members can view and edit

Anyone with the link can view

Notifications

Get notified about activity in this project

A summary of activity every Monday morning

Submit with an empty or short name to see inline validation. Hit Create project to see the loading and success states.

Multi-step form

Use a step indicator when the form has three or more logical groups and showing them all at once would be overwhelming. Each step validates before advancing — users cannot skip ahead.

1
2
3

Validation timing

TriggerWhen to useExample
onBlurRequired fields, format checksEmail must contain @. Show error after user leaves the field.
onChangeCharacter count, real-time strength meterPassword strength bar updates as user types.
onSubmitFull form sweep + server validationCatch any missed fields before the API call. Show all errors at once.
Server errorUniqueness, permission, business rules"That email is already taken." Map API error to the relevant field.

Implementation

CreateProjectForm.tsx
tsx
"use client";

import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { RadioGroup, Radio } from "@/components/ui/Radio";
import { Switch } from "@/components/ui/Switch";
import { Button } from "@/components/ui/Button";

type FormValues = {
  name: string;
  description: string;
  visibility: "private" | "team" | "public";
  emailAlerts: boolean;
};

export function CreateProjectForm() {
  const [values, setValues] = useState<FormValues>({
    name: "", description: "", visibility: "private", emailAlerts: true,
  });
  const [errors, setErrors] = useState<Partial<FormValues>>({});
  const [loading, setLoading] = useState(false);

  function validate() {
    const errs: Partial<Record<keyof FormValues, string>> = {};
    if (!values.name.trim()) errs.name = "Project name is required.";
    else if (values.name.trim().length < 3) errs.name = "Must be at least 3 characters.";
    return errs;
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const errs = validate();
    if (Object.keys(errs).length) { setErrors(errs); return; }
    setLoading(true);
    await submitToAPI(values);
    setLoading(false);
  }

  return (
    <form onSubmit={handleSubmit} noValidate className="space-y-6">
      {/* Section: Basic info */}
      <fieldset className="space-y-4">
        <legend className="text-label-uppercase">Basic info</legend>
        <Input
          label="Project name"
          value={values.name}
          onChange={(e) => {
            setValues({ ...values, name: e.target.value });
            setErrors({ ...errors, name: "" });
          }}
          onBlur={() => setErrors(validate())}
          error={errors.name}
          required
        />
      </fieldset>

      {/* Section: Visibility */}
      <fieldset className="space-y-3">
        <legend className="text-label-uppercase">Visibility</legend>
        <RadioGroup value={values.visibility} onChange={(v) => setValues({ ...values, visibility: v })}>
          <Radio value="private" label="Private"  helperText="Only you" />
          <Radio value="team"    label="Team"      helperText="All members" />
          <Radio value="public"  label="Public"    helperText="Anyone with the link" />
        </RadioGroup>
      </fieldset>

      {/* Section: Notifications */}
      <fieldset className="space-y-3">
        <legend className="text-label-uppercase">Notifications</legend>
        <Switch
          label="Email alerts"
          checked={values.emailAlerts}
          onChange={(e) => setValues({ ...values, emailAlerts: e.target.checked })}
        />
      </fieldset>

      <Button type="submit" loading={loading}>Create project</Button>
    </form>
  );
}

Design decisions

  • Use fieldsets and legends for grouping. fieldset/legend is the correct semantic element for a group of related controls. Use section titles (small caps, text-tertiary) as visual equivalents when you style the legend visually.
  • Never validate on first focus. Show errors on blur or on submit — never while the user is still typing into a fresh field. Premature errors feel accusatory and cause rage-clicks.
  • Multi-step: validate before advancing. Prevent advancing past a step with invalid fields. Show errors immediately when the user hits Continue, then scroll/focus to the first error.
  • Success state replaces the form. On submit success, replace the form with a confirmation state instead of showing a toast. The form surface itself communicating completion is clearer than an overlay that disappears.
  • Loading state disables the submit button. Prevent double-submission by disabling (or replacing) the button while the request is in flight. Show a spinner or label change ("Creating…") so the user knows work is happening.

Accessibility

  • Every input must have an associated <label> — either via htmlFor/id, or by wrapping. Never use placeholder as the only label.
  • Error messages must be linked to their input via aria-describedby and have role="alert" so screen readers announce them immediately.
  • Required fields: add aria-required="true" to the input and a visible indicator (asterisk) with aria-hidden="true" on the decorative mark.
  • On submit failure, move focus to the first errored field (or an error summary at the top) so keyboard users know where to go.
  • Use <fieldset> + <legend> for radio groups and checkbox groups — not just divs with a paragraph title.
  • The submit button should never be the only way to submit: Enter on the last text field should also trigger submission (the default browser behavior — don't suppress it with e.preventDefault unconditionally).