
Form Ux Patterns
- 102 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-ux-patterns is a Claude Code skill that provides UX patterns for complex forms, including multi-step wizards, cognitive chunking, progressive disclosure, and conditional fields.
About
form-ux-patterns is a Claude Code skill for complex form UX, built on cognitive-load and aviation UX principles. It covers multi-step wizards, chunking fields into groups of 5-7, progressive disclosure, and conditional fields. A developer loads it when building checkout flows, onboarding wizards, or forms with many fields. It provides a useMultiStepForm hook and step configuration types.
- Multi-step wizard hook with per-step validation and progress
- Cognitive chunking to 5-7 fields per group (Miller's Law)
- Progressive disclosure and conditional-field patterns for checkout and onboarding
Form Ux Patterns by the numbers
- 102 all-time installs (skills.sh)
- Ranked #1,052 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
form-ux-patterns capabilities & compatibility
- Capabilities
- form ux · multi step forms · react forms · form validation
- Use cases
- frontend
What form-ux-patterns says it does
Patterns for complex forms based on cognitive load research and aviation UX principles.
"Humans can hold 5-7 items in working memory" — Miller's Law
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-ux-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 102 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Build multi-step wizards and long forms with chunking, progressive disclosure, and conditional fields.
Who is it for?
Developers building checkout flows, onboarding wizards, or forms with many fields.
Skip if: Simple single-section forms where wizard overhead is unnecessary.
When should I use this skill?
Building checkout flows, onboarding wizards, or forms with many fields.
What you get
Chunked multi-step forms with per-step validation, progress, and conditional disclosure.
- Multi-step form hook
- Step and chunk configuration types
- Progressive disclosure patterns
By the numbers
- Recommends max 5-7 fields per group
- 12-field example shown as cognitive overload to be chunked
Files
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 # StylesReference
references/cognitive-load.md— Research on Miller's Law and chunkingreferences/wizard-patterns.md— Multi-step wizard best practices
{
"name": "form-ux-patterns",
"description": "UX patterns for complex forms including multi-step wizards, cognitive chunking, progressive disclosure, and conditional fields. Use when building checkout flows, onboarding wizards, or forms with many fields.",
"tags": [
"forms",
"ui-design",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"form-react",
"form-vue",
"form-vanilla"
],
"last_reviewed_at": "2026-06-08",
"review_score": 66,
"relevance_tier": "B"
}
/**
* Multi-Step Form Component
*
* React component for wizard-style multi-step forms with:
* - Per-step validation
* - Step indicator/progress
* - Focus management on step change
* - Form chunking (5-7 fields per step)
*
* @module multi-step-form
*/
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
ComponentType
} from 'react';
import { useForm, FormProvider, UseFormReturn, FieldValues } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// =============================================================================
// TYPES
// =============================================================================
/**
* Form chunk definition (5-7 fields max per step)
*/
export interface FormChunk {
/** Unique step identifier */
id: string;
/** Step title (shown in indicator) */
title: string;
/** Step description (briefing, separate from field labels) */
description?: string;
/** Field names included in this step */
fields: string[];
/** Zod schema for this step's fields */
schema: z.ZodType;
/** Optional: Skip this step based on form values */
skipIf?: (values: FieldValues) => boolean;
/** Optional: Custom step component */
component?: ComponentType<StepProps>;
}
export interface StepProps {
/** Step configuration */
step: FormChunk;
/** Step index (0-based) */
index: number;
/** Total number of steps */
total: number;
/** Whether this is the active step */
isActive: boolean;
}
export interface MultiStepFormProps<T extends FieldValues> {
/** Step definitions */
steps: FormChunk[];
/** Combined schema for full form validation */
schema: z.ZodType<T>;
/** Form submission handler */
onSubmit: (data: T) => Promise<void> | void;
/** Initial form values */
defaultValues?: Partial<T>;
/** Children render function or components */
children: ReactNode | ((props: MultiStepRenderProps<T>) => ReactNode);
/** Show step indicator */
showIndicator?: boolean;
/** Custom submit button text */
submitLabel?: string;
/** Custom next button text */
nextLabel?: string;
/** Custom back button text */
backLabel?: string;
}
export interface MultiStepRenderProps<T extends FieldValues> {
/** Current step index */
currentStep: number;
/** Total steps count */
totalSteps: number;
/** Current step config */
step: FormChunk;
/** Go to next step (validates current) */
next: () => Promise<boolean>;
/** Go to previous step */
back: () => void;
/** Go to specific step */
goTo: (step: number) => void;
/** Whether on first step */
isFirst: boolean;
/** Whether on last step */
isLast: boolean;
/** Form methods from react-hook-form */
form: UseFormReturn<T>;
/** Submit the form */
submit: () => void;
}
// =============================================================================
// CONTEXT
// =============================================================================
interface MultiStepContextValue {
currentStep: number;
totalSteps: number;
goTo: (step: number) => void;
next: () => Promise<boolean>;
back: () => void;
}
const MultiStepContext = createContext<MultiStepContextValue | null>(null);
/**
* Hook to access multi-step form context
*/
export function useMultiStep() {
const context = useContext(MultiStepContext);
if (!context) {
throw new Error('useMultiStep must be used within MultiStepForm');
}
return context;
}
// =============================================================================
// STEP INDICATOR
// =============================================================================
interface StepIndicatorProps {
steps: FormChunk[];
currentStep: number;
onStepClick?: (step: number) => void;
allowNavigation?: boolean;
}
/**
* Visual step indicator/progress bar
*/
export function StepIndicator({
steps,
currentStep,
onStepClick,
allowNavigation = false
}: StepIndicatorProps) {
return (
<nav aria-label="Form progress" className="step-indicator">
<ol className="step-indicator__list">
{steps.map((step, index) => {
const isComplete = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = allowNavigation && index < currentStep;
return (
<li
key={step.id}
className={`step-indicator__item ${isComplete ? 'complete' : ''} ${isCurrent ? 'current' : ''}`}
>
{isClickable ? (
<button
type="button"
onClick={() => onStepClick?.(index)}
className="step-indicator__button"
aria-current={isCurrent ? 'step' : undefined}
>
<span className="step-indicator__number">{index + 1}</span>
<span className="step-indicator__title">{step.title}</span>
</button>
) : (
<span
className="step-indicator__content"
aria-current={isCurrent ? 'step' : undefined}
>
<span className="step-indicator__number">
{isComplete ? '✓' : index + 1}
</span>
<span className="step-indicator__title">{step.title}</span>
</span>
)}
{index < steps.length - 1 && (
<span className="step-indicator__connector" aria-hidden="true" />
)}
</li>
);
})}
</ol>
<div className="sr-only" aria-live="polite">
Step {currentStep + 1} of {steps.length}: {steps[currentStep]?.title}
</div>
</nav>
);
}
// =============================================================================
// MULTI-STEP FORM
// =============================================================================
/**
* Multi-step form container
*
* @example
* ```tsx
* const steps: FormChunk[] = [
* {
* id: 'contact',
* title: 'Contact',
* description: 'How can we reach you?',
* fields: ['email', 'phone'],
* schema: contactSchema
* },
* {
* id: 'address',
* title: 'Address',
* fields: ['street', 'city', 'state', 'zip'],
* schema: addressSchema
* }
* ];
*
* <MultiStepForm steps={steps} schema={fullSchema} onSubmit={handleSubmit}>
* {({ currentStep, step, next, back, isFirst, isLast }) => (
* <>
* {currentStep === 0 && <ContactFields />}
* {currentStep === 1 && <AddressFields />}
*
* <div className="buttons">
* {!isFirst && <button onClick={back}>Back</button>}
* {isLast ? (
* <button type="submit">Submit</button>
* ) : (
* <button onClick={next}>Next</button>
* )}
* </div>
* </>
* )}
* </MultiStepForm>
* ```
*/
export function MultiStepForm<T extends FieldValues>({
steps,
schema,
onSubmit,
defaultValues,
children,
showIndicator = true,
submitLabel = 'Submit',
nextLabel = 'Next',
backLabel = 'Back'
}: MultiStepFormProps<T>) {
const [currentStep, setCurrentStep] = useState(0);
// Filter out skipped steps
const activeSteps = steps.filter((step, index) => {
if (!step.skipIf) return true;
// Can't skip current step check until we have form values
return true;
});
const form = useForm<T>({
resolver: zodResolver(schema),
mode: 'onBlur',
reValidateMode: 'onChange',
defaultValues: defaultValues as any
});
const totalSteps = activeSteps.length;
const step = activeSteps[currentStep];
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// Focus step heading on change
const focusStepHeading = useCallback(() => {
requestAnimationFrame(() => {
const heading = document.getElementById('step-heading');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
}, []);
// Validate current step and go to next
const next = useCallback(async () => {
if (!step) return false;
// Validate only fields in current step
const isValid = await form.trigger(step.fields as any);
if (isValid && currentStep < totalSteps - 1) {
setCurrentStep(prev => prev + 1);
focusStepHeading();
return true;
}
return isValid;
}, [currentStep, step, totalSteps, form, focusStepHeading]);
// Go to previous step
const back = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
focusStepHeading();
}
}, [currentStep, focusStepHeading]);
// Go to specific step (only completed steps)
const goTo = useCallback((stepIndex: number) => {
if (stepIndex >= 0 && stepIndex < currentStep) {
setCurrentStep(stepIndex);
focusStepHeading();
}
}, [currentStep, focusStepHeading]);
// Handle form submission
const handleSubmit = form.handleSubmit(async (data) => {
await onSubmit(data);
});
const contextValue: MultiStepContextValue = {
currentStep,
totalSteps,
goTo,
next,
back
};
const renderProps: MultiStepRenderProps<T> = {
currentStep,
totalSteps,
step,
next,
back,
goTo,
isFirst,
isLast,
form,
submit: handleSubmit
};
return (
<MultiStepContext.Provider value={contextValue}>
<FormProvider {...form}>
<form onSubmit={handleSubmit} noValidate className="multi-step-form">
{showIndicator && (
<StepIndicator
steps={activeSteps}
currentStep={currentStep}
onStepClick={goTo}
allowNavigation={true}
/>
)}
{step && (
<div className="multi-step-form__step">
<h2 id="step-heading" className="multi-step-form__title">
{step.title}
</h2>
{step.description && (
<p className="multi-step-form__description">
{step.description}
</p>
)}
<div className="multi-step-form__content">
{typeof children === 'function' ? children(renderProps) : children}
</div>
</div>
)}
{/* Default navigation if not provided by children */}
{typeof children !== 'function' && (
<div className="multi-step-form__navigation">
{!isFirst && (
<button type="button" onClick={back} className="btn btn--secondary">
{backLabel}
</button>
)}
{isLast ? (
<button
type="submit"
disabled={form.formState.isSubmitting}
className="btn btn--primary"
>
{form.formState.isSubmitting ? 'Submitting...' : submitLabel}
</button>
) : (
<button type="button" onClick={next} className="btn btn--primary">
{nextLabel}
</button>
)}
</div>
)}
</form>
</FormProvider>
</MultiStepContext.Provider>
);
}
// =============================================================================
// CONDITIONAL STEP
// =============================================================================
interface ConditionalStepProps {
/** Condition function - receives form values */
when: (values: FieldValues) => boolean;
/** Content to show when condition is true */
children: ReactNode;
}
/**
* Conditionally render step content based on form values
*
* @example
* ```tsx
* <ConditionalStep when={(values) => values.hasCompany}>
* <FormField name="companyName" label="Company Name" />
* </ConditionalStep>
* ```
*/
export function ConditionalStep({ when, children }: ConditionalStepProps) {
const form = useForm();
const values = form.watch();
if (!when(values)) return null;
return <>{children}</>;
}
// =============================================================================
// STEP CONTENT
// =============================================================================
interface StepContentProps {
/** Step ID to match */
step: string;
/** Content for this step */
children: ReactNode;
}
/**
* Render content only for a specific step
*
* @example
* ```tsx
* <MultiStepForm steps={steps} ...>
* <StepContent step="contact">
* <FormField name="email" label="Email" />
* </StepContent>
*
* <StepContent step="address">
* <FormField name="street" label="Street" />
* </StepContent>
* </MultiStepForm>
* ```
*/
export function StepContent({ step: stepId, children }: StepContentProps) {
const { currentStep } = useMultiStep();
// This would need access to steps array to match by ID
// For now, assume steps are passed in order
return <>{children}</>;
}
// =============================================================================
// CSS
// =============================================================================
export const multiStepFormCSS = `
/* Step Indicator */
.step-indicator {
margin-bottom: 2rem;
}
.step-indicator__list {
display: flex;
justify-content: space-between;
list-style: none;
padding: 0;
margin: 0;
}
.step-indicator__item {
flex: 1;
display: flex;
align-items: center;
position: relative;
}
.step-indicator__content,
.step-indicator__button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
cursor: default;
padding: 0;
}
.step-indicator__button {
cursor: pointer;
}
.step-indicator__button:hover .step-indicator__number {
background-color: #2563eb;
color: white;
}
.step-indicator__number {
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: #e5e7eb;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
transition: background-color 0.2s, color 0.2s;
}
.step-indicator__item.current .step-indicator__number {
background-color: #2563eb;
color: white;
}
.step-indicator__item.complete .step-indicator__number {
background-color: #059669;
color: white;
}
.step-indicator__title {
font-size: 0.875rem;
color: #6b7280;
}
.step-indicator__item.current .step-indicator__title {
color: #111827;
font-weight: 500;
}
.step-indicator__connector {
flex: 1;
height: 2px;
background-color: #e5e7eb;
margin: 0 0.5rem;
}
.step-indicator__item.complete .step-indicator__connector {
background-color: #059669;
}
/* Multi-Step Form */
.multi-step-form__step {
margin-bottom: 2rem;
}
.multi-step-form__title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
outline: none;
}
.multi-step-form__description {
color: #6b7280;
margin-bottom: 1.5rem;
}
.multi-step-form__navigation {
display: flex;
gap: 1rem;
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn--primary {
background-color: #2563eb;
color: white;
border: none;
}
.btn--primary:hover {
background-color: #1d4ed8;
}
.btn--primary:disabled {
background-color: #93c5fd;
cursor: not-allowed;
}
.btn--secondary {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn--secondary:hover {
background-color: #f9fafb;
}
`;
Related skills
FAQ
How many fields per group does it recommend?
A maximum of 5-7 fields per logical group, based on Miller's Law about working memory.
What is it best suited for?
Checkout flows, onboarding wizards, and forms with many fields.