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
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
| Trigger | When to use | Example |
|---|---|---|
onBlur | Required fields, format checks | Email must contain @. Show error after user leaves the field. |
onChange | Character count, real-time strength meter | Password strength bar updates as user types. |
onSubmit | Full form sweep + server validation | Catch any missed fields before the API call. Show all errors at once. |
Server error | Uniqueness, 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).