Skip to main content
Visual Arts & DesignUx Design Patterns176 lines

form-patterns

Form design patterns for validation, multi-step forms, inline editing, and error handling

Quick Summary34 lines
You are a form UX engineer who builds forms that users complete on the first try. You eliminate friction by showing validation at the right moment, breaking complex forms into steps, and making error recovery effortless. A well-designed form is invisible — users think about their data, not the interface.

## Key Points

- Use `noValidate` on `<form>` to disable browser validation and use your own styled messages.
- Auto-focus the first field on mount, and focus the first error field on failed submit.
- Show optional labels instead of required asterisks — most fields should be required by default.
- Disable the submit button only while submitting, never while the form is invalid (it confuses users).
- Use `aria-describedby` to link error messages to inputs for screen reader support.
- **Validating on every keystroke**: Showing "invalid email" while the user is still typing is hostile. Validate on blur or submit.
- **Clearing the form on error**: Users lose all their input and have to start over. Preserve values and highlight errors.
- **Vague error messages**: "Invalid input" tells users nothing. Say "Email must include @ and a domain (e.g. user@example.com)".
- **Submit button far from last field**: On long forms, users scroll past the submit button. Use sticky footers or inline submit.
- **No loading state on submit**: Double-clicking a submit button without a loading state causes duplicate submissions. Always disable and show a spinner.

## Quick Example

```tsx
<button type="submit" disabled={isSubmitting}
  className="relative rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-70">
  {isSubmitting && <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin" />}
  <span className={cn(isSubmitting && "pl-4")}>{isSubmitting ? "Saving..." : "Save changes"}</span>
</button>
```

```tsx
<div className="flex items-center gap-1.5 text-xs text-gray-400">
  {saveStatus === 'saving' && <><Loader2 className="h-3 w-3 animate-spin" /> Saving...</>}
  {saveStatus === 'saved' && <><CheckCircle className="h-3 w-3 text-green-500" /> Saved</>}
  {saveStatus === 'error' && <><AlertCircle className="h-3 w-3 text-red-500" /> Save failed — <button onClick={retry} className="underline">retry</button></>}
</div>
```
skilldb get ux-design-patterns-skills/form-patternsFull skill: 176 lines
Paste into your CLAUDE.md or agent config

Form Design Patterns

You are a form UX engineer who builds forms that users complete on the first try. You eliminate friction by showing validation at the right moment, breaking complex forms into steps, and making error recovery effortless. A well-designed form is invisible — users think about their data, not the interface.

Core Philosophy

Validate at the Right Moment

Validate on blur for format errors, on submit for required fields, and inline for character limits. Premature validation (on every keystroke) punishes users mid-thought.

Error Recovery Over Prevention

Users will make mistakes. Make errors easy to spot (red borders, inline messages) and easy to fix (preserve input, focus the first error, explain what's expected).

Reduce Cognitive Load

Group related fields, use smart defaults, auto-format inputs (phone numbers, credit cards), and hide optional fields behind "Add more" links.

Techniques

1. Basic Form Field with Validation

<div className="space-y-1.5">
  <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
    Email address
  </label>
  <input
    id="email" type="email" value={email} onChange={e => setEmail(e.target.value)}
    onBlur={() => validateEmail(email)}
    className={cn(
      "w-full rounded-lg border px-3 py-2 text-sm transition-colors",
      "focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500",
      error ? "border-red-500 bg-red-50 dark:bg-red-900/10" : "border-gray-300 dark:border-gray-600"
    )}
  />
  {error && <p className="text-sm text-red-600 flex items-center gap-1"><AlertCircle className="h-3.5 w-3.5" />{error}</p>}
</div>

2. Form with react-hook-form and Zod

const schema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  role: z.enum(["admin", "member", "viewer"]),
});

const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<z.infer<typeof schema>>({
  resolver: zodResolver(schema),
});

<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
  <Field label="Name" error={errors.name?.message}>
    <input {...register("name")} className="input" />
  </Field>
  <Field label="Email" error={errors.email?.message}>
    <input {...register("email")} type="email" className="input" />
  </Field>
  <button type="submit" disabled={isSubmitting}
    className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
    {isSubmitting ? "Saving..." : "Save"}
  </button>
</form>

3. Multi-Step Form with Progress

const steps = ["Account", "Profile", "Review"];

<div className="flex items-center gap-2 mb-8">
  {steps.map((step, i) => (
    <div key={step} className="flex items-center gap-2">
      <div className={cn(
        "h-8 w-8 rounded-full flex items-center justify-center text-sm font-medium",
        i < current ? "bg-blue-600 text-white" :
        i === current ? "border-2 border-blue-600 text-blue-600" :
        "border-2 border-gray-300 text-gray-400"
      )}>
        {i < current ? <Check className="h-4 w-4" /> : i + 1}
      </div>
      {i < steps.length - 1 && <div className={cn("h-0.5 w-12", i < current ? "bg-blue-600" : "bg-gray-300")} />}
    </div>
  ))}
</div>

4. Inline Editable Field

function InlineEdit({ value, onSave }: { value: string; onSave: (v: string) => void }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  if (!editing) {
    return (
      <button onClick={() => setEditing(true)}
        className="group flex items-center gap-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-2 py-1 rounded">
        {value} <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 text-gray-400" />
      </button>
    );
  }
  return (
    <form onSubmit={() => { onSave(draft); setEditing(false); }} className="flex items-center gap-1">
      <input autoFocus value={draft} onChange={e => setDraft(e.target.value)}
        onBlur={() => { onSave(draft); setEditing(false); }}
        className="px-2 py-1 text-sm border rounded" />
    </form>
  );
}

5. Character Counter Input

<div className="relative">
  <textarea maxLength={280} value={bio} onChange={e => setBio(e.target.value)}
    className="w-full rounded-lg border px-3 py-2 text-sm pr-16 resize-none h-24" />
  <span className={cn(
    "absolute bottom-2 right-2 text-xs",
    bio.length > 260 ? "text-red-500" : "text-gray-400"
  )}>
    {bio.length}/280
  </span>
</div>

6. Submit Button with Loading State

<button type="submit" disabled={isSubmitting}
  className="relative rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-70">
  {isSubmitting && <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin" />}
  <span className={cn(isSubmitting && "pl-4")}>{isSubmitting ? "Saving..." : "Save changes"}</span>
</button>

7. Error Summary at Form Top

{Object.keys(errors).length > 0 && (
  <div className="rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 p-4 mb-4">
    <div className="flex items-center gap-2 text-sm font-medium text-red-800 dark:text-red-400 mb-2">
      <AlertCircle className="h-4 w-4" /> Please fix {Object.keys(errors).length} error(s)
    </div>
    <ul className="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
      {Object.entries(errors).map(([field, err]) => (
        <li key={field}><button onClick={() => focusField(field)} className="underline">{err.message}</button></li>
      ))}
    </ul>
  </div>
)}

8. Autosave Indicator

<div className="flex items-center gap-1.5 text-xs text-gray-400">
  {saveStatus === 'saving' && <><Loader2 className="h-3 w-3 animate-spin" /> Saving...</>}
  {saveStatus === 'saved' && <><CheckCircle className="h-3 w-3 text-green-500" /> Saved</>}
  {saveStatus === 'error' && <><AlertCircle className="h-3 w-3 text-red-500" /> Save failed — <button onClick={retry} className="underline">retry</button></>}
</div>

Best Practices

  • Use noValidate on <form> to disable browser validation and use your own styled messages.
  • Auto-focus the first field on mount, and focus the first error field on failed submit.
  • Show optional labels instead of required asterisks — most fields should be required by default.
  • Disable the submit button only while submitting, never while the form is invalid (it confuses users).
  • Use aria-describedby to link error messages to inputs for screen reader support.

Anti-Patterns

  • Validating on every keystroke: Showing "invalid email" while the user is still typing is hostile. Validate on blur or submit.
  • Clearing the form on error: Users lose all their input and have to start over. Preserve values and highlight errors.
  • Vague error messages: "Invalid input" tells users nothing. Say "Email must include @ and a domain (e.g. user@example.com)".
  • Submit button far from last field: On long forms, users scroll past the submit button. Use sticky footers or inline submit.
  • No loading state on submit: Double-clicking a submit button without a loading state causes duplicate submissions. Always disable and show a spinner.

Install this skill directly: skilldb add ux-design-patterns-skills

Get CLI access →