Claude-skill-registry form-ux-patterns
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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/form-ux-patterns" ~/.claude/skills/majiayu000-claude-skill-registry-form-ux-patterns && rm -rf "$T"
manifest:
skills/data/form-ux-patterns/SKILL.mdsource content
Form UX Patterns
Patterns for complex forms based on cognitive load research and aviation UX principles.
Quick Start
// 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
// ❌ 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.
// ❌ 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.
// 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
// 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
// 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
// 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 ? 'current' : 'upcoming'; const clickable = canNavigate?.(index) ?? false; return ( <li key={step.id} className={`step-indicator__item step-indicator__item--${status}`} > {clickable ? ( <button type="button" onClick={() => onStepClick?.(index)} aria-current={status === 'current' ? 'step' : undefined} > <span className="step-indicator__number">{index + 1}</span> <span className="step-indicator__title">{step.title}</span> </button> ) : ( <span aria-current={status === 'current' ? 'step' : undefined}> <span className="step-indicator__number">{index + 1}</span> <span className="step-indicator__title">{step.title}</span> </span> )} </li> ); })} </ol> {/* Progress bar */} <div className="step-indicator__progress" role="progressbar" aria-valuenow={currentStep + 1} aria-valuemin={1} aria-valuemax={steps.length} aria-label={`Step ${currentStep + 1} of ${steps.length}`} > <div className="step-indicator__progress-fill" style={{ width: `${((currentStep + 1) / steps.length) * 100}%` }} /> </div> </nav> ); }
Step Navigation Component
// 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 ( <div className="step-navigation"> {!isFirstStep && ( <button type="button" onClick={onBack} className="step-navigation__back" disabled={isSubmitting} > {backLabel} </button> )} <button type="button" onClick={onNext} className="step-navigation__next" disabled={isSubmitting} > {isSubmitting ? ( <> <Spinner aria-hidden="true" /> <span className="sr-only">Processing...</span> Processing... </> ) : ( isLastStep ? submitLabel : nextLabel )} </button> </div> ); }
Conditional Fields
Pattern: Show/Hide Based on Selection
// 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 <FormField name="hasCompany" label="Are you a business?" type="checkbox" /> <ConditionalField watch="hasCompany" when={(v) => v === true}> <FormField name="companyName" label="Company name" /> <FormField name="taxId" label="Tax ID" /> </ConditionalField>
Pattern: Dynamic Field Array
// 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 ( <fieldset className="repeatable-field"> <legend>{label}</legend> {fields.map((field, index) => ( <div key={field.id} className="repeatable-field__item"> {renderItem(index)} {canRemove && ( <button type="button" onClick={() => remove(index)} aria-label={`Remove item ${index + 1}`} > Remove </button> )} </div> ))} {canAdd && ( <button type="button" onClick={() => append({})} className="repeatable-field__add" > Add {label.toLowerCase()} </button> )} </fieldset> ); } // Usage <RepeatableField name="teammates" label="Team Members" maxItems={5} renderItem={(index) => ( <> <FormField name={`teammates.${index}.name`} label="Name" /> <FormField name={`teammates.${index}.email`} label="Email" /> </> )} />
Form Layout Patterns
Single Column (Recommended Default)
// Best for most forms - clear visual flow <form className="form-layout--single"> <FormField name="email" label="Email" /> <FormField name="password" label="Password" /> <button type="submit">Sign in</button> </form> // CSS .form-layout--single { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; }
Two Column (Use Sparingly)
// Only for related short fields <form className="form-layout--two-col"> <FormField name="firstName" label="First name" /> <FormField name="lastName" label="Last name" /> <FormField name="city" label="City" className="col-span-1" /> <FormField name="state" label="State" className="col-span-1" /> </form> // 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
// For long forms with distinct sections <form className="form-layout--cards"> <section className="form-card"> <h3>Contact Information</h3> <FormField name="email" label="Email" /> <FormField name="phone" label="Phone" /> </section> <section className="form-card"> <h3>Shipping Address</h3> <AddressFields /> </section> <section className="form-card"> <h3>Payment</h3> <PaymentFields /> </section> </form>
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
— Research on Miller's Law and chunkingreferences/cognitive-load.md
— Multi-step wizard best practicesreferences/wizard-patterns.md