--- name: form-ux-patterns description: 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. --- # 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 (
); } ``` ## 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
{/* 12 fields = cognitive overload */}
// ✅ GOOD: Chunked into logical groups (5-7 max per group)
Contact (2 fields)
Shipping (5 fields)
Payment (4 fields)
``` ### 2. Briefing vs. Checklist (Aviation Principle) > Instructions should be separate from labels, given before the task. ```tsx // ❌ BAD: Instructions mixed with labels // ✅ GOOD: Briefing before, label during

Create a strong password with:

  • At least 8 characters
  • Uppercase and lowercase letters
  • At least one number
``` ### 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 (
{/* Only show address for shipping methods */} {method !== 'pickup' && ( )} {/* Only show store selector for pickup */} {method === 'pickup' && ( )} ); } ``` ## 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) => 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; 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; /** 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 ( ); } ``` ### Step Navigation Component ```tsx // components/StepNavigation.tsx interface StepNavigationProps { onBack: () => void; onNext: () => void; isFirstStep: boolean; isLastStep: boolean; isSubmitting?: boolean; backLabel?: string; nextLabel?: string; submitLabel?: string; } export function StepNavigation({ onBack, onNext, isFirstStep, isLastStep, isSubmitting = false, backLabel = 'Back', nextLabel = 'Continue', submitLabel = 'Submit' }: StepNavigationProps) { return (
{!isFirstStep && ( )}
); } ``` ## Conditional Fields ### Pattern: Show/Hide Based on Selection ```tsx // components/ConditionalField.tsx import { useFormContext, useWatch } from 'react-hook-form'; import { ReactNode } from 'react'; interface ConditionalFieldProps { /** Field to watch */ watch: string; /** Condition for showing children */ when: (value: any) => boolean; /** Children to render when condition is true */ children: ReactNode; /** Whether to keep values when hidden */ keepValues?: boolean; } export function ConditionalField({ watch: watchField, when, children, keepValues = false }: ConditionalFieldProps) { const { control, unregister } = useFormContext(); const value = useWatch({ control, name: watchField }); const shouldShow = when(value); // Optionally unregister fields when hidden useEffect(() => { if (!shouldShow && !keepValues) { // Get field names from children and unregister // (implementation depends on your field structure) } }, [shouldShow, keepValues]); if (!shouldShow) return null; return <>{children}; } // Usage v === true}> ``` ### Pattern: Dynamic Field Array ```tsx // components/RepeatableField.tsx import { useFieldArray, useFormContext } from 'react-hook-form'; interface RepeatableFieldProps { name: string; label: string; maxItems?: number; minItems?: number; renderItem: (index: number) => ReactNode; } export function RepeatableField({ name, label, maxItems = 10, minItems = 1, renderItem }: RepeatableFieldProps) { const { control } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, name }); const canAdd = fields.length < maxItems; const canRemove = fields.length > minItems; return (
{label} {fields.map((field, index) => (
{renderItem(index)} {canRemove && ( )}
))} {canAdd && ( )}
); } // Usage ( <> )} /> ``` ## Form Layout Patterns ### Single Column (Recommended Default) ```tsx // Best for most forms - clear visual flow
// CSS .form-layout--single { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; } ``` ### Two Column (Use Sparingly) ```tsx // Only for related short fields
// CSS .form-layout--two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } @media (max-width: 640px) { .form-layout--two-col { grid-template-columns: 1fr; } } ``` ### Card Sections ```tsx // For long forms with distinct sections

Contact Information

Shipping Address

Payment

``` ## File Structure ``` form-ux-patterns/ ├── SKILL.md ├── references/ │ ├── cognitive-load.md # Research on chunking │ └── wizard-patterns.md # Multi-step best practices └── scripts/ ├── multi-step-form.tsx # Multi-step hook + components ├── conditional-field.tsx # Show/hide patterns ├── repeatable-field.tsx # Dynamic arrays ├── step-indicator.tsx # Progress indicator └── step-indicator.css # Styles ``` ## Reference - `references/cognitive-load.md` — Research on Miller's Law and chunking - `references/wizard-patterns.md` — Multi-step wizard best practices