Claude
Skills
Sign in
Back

form-ux-patterns

Included with Lifetime
$97 forever

UX patterns for complex forms including multi-step wizards, cognitive chunking (5-7 fields max), progressive disclosure, and conditional fields. Use when building checkout flows, onboarding wizards, or forms with many fields.

Designscripts

What this skill does


# Form UX Patterns

Patterns for complex forms based on cognitive load research and aviation UX principles.

## Quick Start

```tsx
// Multi-step form with chunking
import { useMultiStepForm } from './multi-step-form';

function CheckoutWizard() {
  const { currentStep, steps, goNext, goBack, isLastStep } = useMultiStepForm({
    steps: [
      { id: 'contact', title: 'Contact', fields: ['email', 'phone'] },
      { id: 'shipping', title: 'Shipping', fields: ['name', 'street', 'city', 'state', 'zip'] },
      { id: 'payment', title: 'Payment', fields: ['cardName', 'cardNumber', 'expiry', 'cvv'] }
    ]
  });

  return (
    <form>
      <StepIndicator steps={steps} current={currentStep} />
      <StepContent step={steps[currentStep]} />
      <StepNavigation onBack={goBack} onNext={goNext} isLast={isLastStep} />
    </form>
  );
}
```

## Core Principles

### 1. Cognitive Chunking (Aviation Principle)

> "Humans can hold 5-7 items in working memory" — Miller's Law

```tsx
// ❌ BAD: All fields on one page
<form>
  <input name="email" />
  <input name="phone" />
  <input name="name" />
  <input name="street" />
  <input name="street2" />
  <input name="city" />
  <input name="state" />
  <input name="zip" />
  <input name="cardName" />
  <input name="cardNumber" />
  <input name="expiry" />
  <input name="cvv" />
  {/* 12 fields = cognitive overload */}
</form>

// ✅ GOOD: Chunked into logical groups (5-7 max per group)
<form>
  <fieldset>
    <legend>Contact (2 fields)</legend>
    <input name="email" />
    <input name="phone" />
  </fieldset>
  
  <fieldset>
    <legend>Shipping (5 fields)</legend>
    <input name="name" />
    <input name="street" />
    <input name="city" />
    <input name="state" />
    <input name="zip" />
  </fieldset>
  
  <fieldset>
    <legend>Payment (4 fields)</legend>
    <input name="cardName" />
    <input name="cardNumber" />
    <input name="expiry" />
    <input name="cvv" />
  </fieldset>
</form>
```

### 2. Briefing vs. Checklist (Aviation Principle)

> Instructions should be separate from labels, given before the task.

```tsx
// ❌ BAD: Instructions mixed with labels
<label>
  Password (must be 8+ characters with uppercase, lowercase, and number)
</label>
<input type="password" />

// ✅ GOOD: Briefing before, label during
<div className="field-briefing">
  <p>Create a strong password with:</p>
  <ul>
    <li>At least 8 characters</li>
    <li>Uppercase and lowercase letters</li>
    <li>At least one number</li>
  </ul>
</div>

<label>Password</label>
<input type="password" />
```

### 3. Progressive Disclosure

> Show only what's needed, when it's needed.

```tsx
// Reveal fields based on selection
function ShippingForm() {
  const [method, setMethod] = useState<'standard' | 'express' | 'pickup'>('standard');

  return (
    <form>
      <RadioGroup
        label="Delivery method"
        value={method}
        onChange={setMethod}
        options={[
          { value: 'standard', label: 'Standard (5-7 days)' },
          { value: 'express', label: 'Express (2-3 days)' },
          { value: 'pickup', label: 'Store pickup' }
        ]}
      />

      {/* Only show address for shipping methods */}
      {method !== 'pickup' && (
        <AddressFields />
      )}

      {/* Only show store selector for pickup */}
      {method === 'pickup' && (
        <StoreSelector />
      )}
    </form>
  );
}
```

## Multi-Step Forms

### Step Configuration

```typescript
// types/multi-step.ts
export interface FormStep {
  /** Unique step identifier */
  id: string;
  
  /** Display title */
  title: string;
  
  /** Optional description (briefing) */
  description?: string;
  
  /** Fields in this step (for validation) */
  fields: string[];
  
  /** Zod schema for this step */
  schema?: z.ZodType;
  
  /** Whether step can be skipped */
  optional?: boolean;
  
  /** Condition for showing this step */
  condition?: (formData: Record<string, any>) => boolean;
}

export interface FormChunk {
  /** Chunk identifier */
  id: string;
  
  /** Chunk title */
  title: string;
  
  /** Briefing text (shown before fields) */
  briefing?: string;
  
  /** Fields in this chunk (max 5-7) */
  fields: string[];
}
```

### Multi-Step Hook

```typescript
// hooks/use-multi-step-form.ts
import { useState, useCallback, useMemo } from 'react';
import { UseFormReturn } from 'react-hook-form';

export interface UseMultiStepFormOptions {
  steps: FormStep[];
  form: UseFormReturn<any>;
  onComplete?: (data: any) => void;
}

export interface UseMultiStepFormReturn {
  /** Current step index */
  currentStep: number;
  
  /** Current step config */
  step: FormStep;
  
  /** All steps (filtered by conditions) */
  steps: FormStep[];
  
  /** Total step count */
  totalSteps: number;
  
  /** Whether on first step */
  isFirstStep: boolean;
  
  /** Whether on last step */
  isLastStep: boolean;
  
  /** Progress percentage (0-100) */
  progress: number;
  
  /** Go to next step (validates current) */
  goNext: () => Promise<boolean>;
  
  /** Go to previous step */
  goBack: () => void;
  
  /** Go to specific step */
  goTo: (index: number) => void;
  
  /** Can navigate to step (all previous valid) */
  canGoTo: (index: number) => boolean;
}

export function useMultiStepForm({
  steps: allSteps,
  form,
  onComplete
}: UseMultiStepFormOptions): UseMultiStepFormReturn {
  const [currentStep, setCurrentStep] = useState(0);
  
  // Filter steps by conditions
  const steps = useMemo(() => {
    const data = form.getValues();
    return allSteps.filter(step => 
      !step.condition || step.condition(data)
    );
  }, [allSteps, form]);
  
  const step = steps[currentStep];
  const totalSteps = steps.length;
  const isFirstStep = currentStep === 0;
  const isLastStep = currentStep === totalSteps - 1;
  const progress = ((currentStep + 1) / totalSteps) * 100;
  
  const goNext = useCallback(async () => {
    // Validate current step fields
    const isValid = await form.trigger(step.fields as any);
    
    if (!isValid) {
      // Focus first error
      const firstError = document.querySelector('[aria-invalid="true"]');
      (firstError as HTMLElement)?.focus();
      return false;
    }
    
    if (isLastStep) {
      // Submit form
      const data = form.getValues();
      onComplete?.(data);
    } else {
      setCurrentStep(prev => prev + 1);
      // Focus step heading
      requestAnimationFrame(() => {
        document.getElementById('step-heading')?.focus();
      });
    }
    
    return true;
  }, [step, isLastStep, form, onComplete]);
  
  const goBack = useCallback(() => {
    if (!isFirstStep) {
      setCurrentStep(prev => prev - 1);
      requestAnimationFrame(() => {
        document.getElementById('step-heading')?.focus();
      });
    }
  }, [isFirstStep]);
  
  const goTo = useCallback((index: number) => {
    if (index >= 0 && index < totalSteps) {
      setCurrentStep(index);
    }
  }, [totalSteps]);
  
  const canGoTo = useCallback((index: number) => {
    // Can always go back
    if (index < currentStep) return true;
    
    // Can only go forward if all previous steps are valid
    // (would need form state tracking for this)
    return index <= currentStep;
  }, [currentStep]);
  
  return {
    currentStep,
    step,
    steps,
    totalSteps,
    isFirstStep,
    isLastStep,
    progress,
    goNext,
    goBack,
    goTo,
    canGoTo
  };
}
```

### Step Indicator Component

```tsx
// components/StepIndicator.tsx
interface StepIndicatorProps {
  steps: FormStep[];
  currentStep: number;
  onStepClick?: (index: number) => void;
  canNavigate?: (index: number) => boolean;
}

export function StepIndicator({
  steps,
  currentStep,
  onStepClick,
  canNavigate
}: StepIndicatorProps) {
  return (
    <nav aria-label="Form progress">
      <ol className="step-indicator">
        {steps.map((step, index) => {
          const status = index < currentStep 
            ? 'complete' 
            : index === currentStep 

Related in Design