---
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
// ✅ GOOD: Chunked into logical groups (5-7 max per group)
```
### 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 (
);
}
```
## 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 (
);
}
// 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
```
## 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