Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add Bbeierle12/Skill-MCP-Claude --skill "form-ux-patterns"
Install specific skill from multi-skill repository
# 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.
# SKILL.md
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
// 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
references/cognitive-load.mdβ Research on Miller's Law and chunkingreferences/wizard-patterns.mdβ Multi-step wizard best practices
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.