AgentStack
SKILL verified MIT Self-run

Form Ux Patterns

skill-bbeierle12-skill-mcp-claude-form-ux-patterns · by Bbeierle12

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.

No reviews yet
0 installs
11 views
0.0% view→install

Install

$ agentstack add skill-bbeierle12-skill-mcp-claude-form-ux-patterns

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Form Ux Patterns? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

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 (
    
      
      
      
    
  );
}

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

  
  
  
  
  
  
  
  
  
  
  
  
  {/* 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.

// ❌ BAD: Instructions mixed with labels

  Password (must be 8+ characters with uppercase, lowercase, and number)

// ✅ GOOD: Briefing before, label during

  Create a strong password with:
  
    At least 8 characters
    Uppercase and lowercase letters
    At least one number
  

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');

  return (
    
      

      {/* Only show address for shipping methods */}
      {method !== 'pickup' && (
        
      )}

      {/* Only show store selector for pickup */}
      {method === 'pickup' && (
        
      )}
    
  );
}

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) => 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;
  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  {
    // Can always go back
    if (index  void;
  canNavigate?: (index: number) => boolean;
}

export function StepIndicator({
  steps,
  currentStep,
  onStepClick,
  canNavigate
}: StepIndicatorProps) {
  return (
    
      
        {steps.map((step, index) => {
          const status = index 
              {clickable ? (
                 onStepClick?.(index)}
                  aria-current={status === 'current' ? 'step' : undefined}
                >
                  {index + 1}
                  {step.title}
                
              ) : (
                
                  {index + 1}
                  {step.title}
                
              )}
            
          );
        })}
      
      
      {/* Progress bar */}
      
        
      
    
  );
}

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 (
    
      {!isFirstStep && (
        
          {backLabel}
        
      )}
      
      
        {isSubmitting ? (
          <>
            
            Processing...
            Processing...
          
        ) : (
          isLastStep ? submitLabel : nextLabel
        )}
      
    
  );
}

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

 v === true}>
  
  

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  minItems;
  
  return (
    
      {label}
      
      {fields.map((field, index) => (
        
          {renderItem(index)}
          
          {canRemove && (
             remove(index)}
              aria-label={`Remove item ${index + 1}`}
            >
              Remove
            
          )}
        
      ))}
      
      {canAdd && (
         append({})}
          className="repeatable-field__add"
        >
          Add {label.toLowerCase()}
        
      )}
    
  );
}

// Usage
 (
    <>
      
      
    
  )}
/>

Form Layout Patterns

Single Column (Recommended Default)

// Best for most forms - clear visual flow

  
  
  Sign in

// CSS
.form-layout--single {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 400px;
}

Two Column (Use Sparingly)

// 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

// 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

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.