gpt-taste
gpt taste
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.
Full skill instructions
Patterns for complex forms based on cognitive load research and aviation UX principles.
// 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>
);
}
"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>
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" />
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>
);
}
// 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[];
}
// 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
};
}
// 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>
);
}
// 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>
);
}
// 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>
// 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" />
</>
)}
/>
// 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;
}
// 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;
}
}
// 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>
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
references/βcognitive-load.md β Research on Miller's Law and chunkingreferences/βwizard-patterns.md β Multi-step wizard best practicesgpt taste
Premium brand-kit image generation for high-end identity systems, logo concepts, and visual-world presentations.
Premium website design-to-code skill that generates visual references first, then builds faithful frontend implementations.
Premium frontend image direction for landing pages and marketing sites, generating one distinct horizontal image per section.
Premium mobile app screen concepts and flows with clean hierarchy, strong imagery, and distinctive art direction.
Emil Kowalski's philosophy on UI polish, component design, and the invisible details that make software feel good.
Frontend design and iteration for production-grade interfaces, from concept to ship.
Reverse-engineer design tokens from public websites into starter token files for your project.
Builds an animation from scratch by making the decisions in the order that determines whether it feels right.
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.
Chat-to-edit AI studio for product photos, portraits, branding, and video.
Generate your dream room in seconds.
Redesign your interior in seconds using AI
Transform rooms with AI-generated designs.
Transform Your Space with AI-Powered Japanese Interior Designs by Jife
AI-powered wireframes for non-designers.
Revolutionize Property Viewing with AI-Generated Floor Plans and Virtual Tours
Transform sketches into stunning architectural renders with AI.