# Better Forms

> Complete guide for building accessible, high-UX forms in modern stacks (React/Next.js, Tailwind, Zod). Includes specific patterns for clickable areas, range sliders, output-inspired design, and WCAG compliance.

- Skill: `alexeira/better-forms` (Agent Skill)
- Install (CLI): `npx skillmds@latest add alexeira/better-forms`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alexeira/better-forms/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: alexeira (https://skillmd.com/u/alexeira)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/alexeira/better-forms

---


# Better Forms Guide

A collection of specific UX patterns, accessibility standards, and implementation techniques for modern web forms. This guide bridges the gap between raw HTML/CSS tips and component-based architectures (React, Tailwind, Headless UI).

## 1. High-Impact UX Patterns (The "Why" & "How")

### Avoid "Dead Zones" in Lists

**Concept**: Small gaps between clickable list items create frustration.
**Implementation (Tailwind)**: Use a pseudo-element to expand the hit area without affecting layout.

```tsx
// Do this for list items or radio groups
<div className="relative group">
  <input type="radio" className="..." />
  <label className="... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']">
    Option Label
  </label>
</div>
```

### Range Sliders > Min/Max Inputs

**Concept**: "From $10 to $1000" text inputs are tedious.
**Implementation**: Use a dual-thumb slider component (like Radix UI / Shadcn Slider) for ranges.

- **Why**: Cognitive load reduction and immediate visual feedback.
- **A11y**: Ensure the slider supports arrow key navigation.

### "Output-Inspired" Design

**Concept**: The form inputs should visually resemble the final result card/page.

- **Hierarchy**: If the output title is `text-2xl font-bold`, the input for it should be `text-2xl font-bold`.
- **Placement**: If the image goes on the left in the listing, the upload button goes on the left in the form.
- **Empty States**: Preview what the empty card looks like while filling it.

### Descriptive Action Buttons

**Concept**: Never use "Submit" or "Send". The button should complete the sentence "I want to..."

- Avoid: `Submit`
- Prefer: `Create Account`, `Publish Listing`, `Update Profile`
  **Tip**: Update button text dynamically based on form state (e.g., "Saving..." vs "Save Changes").

### "Optional" Label > Asterisks

**Concept**: Red asterisks (\*) are aggressive and ambiguous (sometimes meaning "error").
**Implementation**: Mark required fields by default (no indicator) and explicitly label optional ones.

```tsx
<Label>
  Phone Number{" "}
  <span className="text-muted-foreground text-sm font-normal">(Optional)</span>
</Label>
```

### Show/Hide Password

**Concept**: Masking passwords by default prevents error correction.
**Implementation**: Always include a toggle button inside the input wrapper.

- **A11y**: The toggle button must have `type="button"` and `aria-label="Show password"`.

### Field Sizing as Affordance

**Concept**: The width of the input suggests the expected data length.

- **Zip Code**: `w-20` or `w-24` (not full width).
- **CVV**: Small width.
- **Street Address**: Full width.

## 2. Advanced UX Patterns

### Input Masking & Formatting

**Concept**: Auto-format data as the user types to reduce errors and cognitive load.

```tsx
// Phone number formatting with react-number-format
import { PatternFormat } from "react-number-format";

<PatternFormat
  format="(###) ###-####"
  mask="_"
  allowEmptyFormatting
  customInput={Input} // Your styled input component
  onValueChange={(values) => {
    // values.value = "1234567890" (raw)
    // values.formattedValue = "(123) 456-7890"
    form.setValue("phone", values.value);
  }}
/>;

// Credit card with automatic spacing
<PatternFormat
  format="#### #### #### ####"
  customInput={Input}
  onValueChange={(values) => form.setValue("cardNumber", values.value)}
/>;

// Currency input
import { NumericFormat } from "react-number-format";

<NumericFormat
  thousandSeparator=","
  prefix="$"
  decimalScale={2}
  fixedDecimalScale
  customInput={Input}
  onValueChange={(values) => form.setValue("amount", values.floatValue)}
/>;
```

**Key Principle**: Store raw values, display formatted values. Never validate formatted strings.

### OTP / 2FA Code Inputs

**Concept**: 6-digit verification codes need special handling for paste, auto-focus, and keyboard navigation.

```tsx
import { useRef, useState, useCallback, ClipboardEvent, KeyboardEvent } from "react";

interface OTPInputProps {
  length?: number;
  onComplete: (code: string) => void;
}

export function OTPInput({ length = 6, onComplete }: OTPInputProps) {
  const [values, setValues] = useState<string[]>(Array(length).fill(""));
  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);

  const focusInput = useCallback((index: number) => {
    const clampedIndex = Math.max(0, Math.min(index, length - 1));
    inputRefs.current[clampedIndex]?.focus();
  }, [length]);

  const handleChange = (index: number, value: string) => {
    if (!/^\d*$/.test(value)) return; // Only digits

    const newValues = [...values];
    newValues[index] = value.slice(-1); // Take last digit only
    setValues(newValues);

    if (value && index < length - 1) {
      focusInput(index + 1);
    }

    const code = newValues.join("");
    if (code.length === length) {
      onComplete(code);
    }
  };

  const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
    switch (e.key) {
      case "Backspace":
        if (!values[index] && index > 0) {
          focusInput(index - 1);
        }
        break;
      case "ArrowLeft":
        e.preventDefault();
        focusInput(index - 1);
        break;
      case "ArrowRight":
        e.preventDefault();
        focusInput(index + 1);
        break;
    }
  };

  const handlePaste = (e: ClipboardEvent) => {
    e.preventDefault();
    const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
    
    if (pastedData) {
      const newValues = [...values];
      pastedData.split("").forEach((char, i) => {
        newValues[i] = char;
      });
      setValues(newValues);
      focusInput(pastedData.length - 1);
      
      if (pastedData.length === length) {
        onComplete(pastedData);
      }
    }
  };

  return (
    <div className="flex gap-2" role="group" aria-label="Verification code">
      {values.map((value, index) => (
        <input
          key={index}
          ref={(el) => { inputRefs.current[index] = el; }}
          type="text"
          inputMode="numeric"
          maxLength={1}
          value={value}
          onChange={(e) => handleChange(index, e.target.value)}
          onKeyDown={(e) => handleKeyDown(index, e)}
          onPaste={handlePaste}
          className="h-12 w-12 text-center text-lg font-semibold border rounded-md
                     focus:ring-2 focus:ring-ring focus:border-transparent"
          aria-label={`Digit ${index + 1} of ${length}`}
        />
      ))}
    </div>
  );
}
```

### Unsaved Changes Protection

**Concept**: Prevent accidental data loss when navigating away from a dirty form.

**Note (React 19)**: Don't confuse `useFormState` from `react-hook-form` with React DOM's `useFormState`, which was renamed to `useActionState` in React 19.

**Warning**: Monkey-patching `router.push` is fragile and may break across Next.js versions. There is no stable API for intercepting App Router navigation. The `beforeunload` approach is the only reliable part. Consider using `onBeforePopState` (Pages Router) or a route change event listener if your framework supports it.

```tsx
import { useEffect } from "react";
import { useFormState } from "react-hook-form";

export function useUnsavedChangesWarning(isDirty: boolean, message?: string) {
  const warningMessage = message ?? "You have unsaved changes. Are you sure you want to leave?";

  // Browser back/refresh — this is the reliable approach
  useEffect(() => {
    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
      if (!isDirty) return;
      e.preventDefault();
    };

    window.addEventListener("beforeunload", handleBeforeUnload);
    return () => window.removeEventListener("beforeunload", handleBeforeUnload);
  }, [isDirty]);

  // For in-app navigation, consider a confirmation modal triggered
  // from your navigation components rather than monkey-patching the router.
}

// Usage with React Hook Form
function EditProfileForm() {
  const form = useForm<ProfileData>();
  const { isDirty } = useFormState({ control: form.control });

  useUnsavedChangesWarning(isDirty);

  return <form>...</form>;
}
```

### Multi-Step Forms (Wizards)

**Concept**: Break complex forms into digestible steps with proper state persistence and focus management.

```tsx
import { useState, useEffect, useRef, useCallback } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

// Persist state to URL for refresh resilience
// Uses lazy state init to read from URL on first render (SSR-safe)
function useStepFromURL() {
  const [step, setStep] = useState(() => {
    if (typeof window === "undefined") return 1;
    const params = new URLSearchParams(window.location.search);
    return parseInt(params.get("step") ?? "1", 10);
  });

  const goToStep = useCallback((newStep: number) => {
    setStep(newStep);
    const url = new URL(window.location.href);
    url.searchParams.set("step", String(newStep));
    window.history.pushState({}, "", url);
  }, []);

  return { step, goToStep };
}

// Focus management on step change
function useStepFocus(step: number) {
  const headingRef = useRef<HTMLHeadingElement>(null);
  
  useEffect(() => {
    // Focus the step heading for screen reader announcement
    headingRef.current?.focus();
  }, [step]);
  
  return headingRef;
}

// Example multi-step form
interface WizardFormData {
  // Step 1
  firstName: string;
  lastName: string;
  // Step 2
  email: string;
  phone: string;
  // Step 3
  address: string;
  city: string;
}

const stepSchemas = {
  1: z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
  2: z.object({ email: z.string().email(), phone: z.string().optional() }),
  3: z.object({ address: z.string().min(1), city: z.string().min(1) }),
};

export function WizardForm() {
  const { step, goToStep } = useStepFromURL();
  const headingRef = useStepFocus(step);
  const totalSteps = 3;
  
  const form = useForm<WizardFormData>({
    resolver: zodResolver(stepSchemas[step as keyof typeof stepSchemas]),
    mode: "onBlur",
  });

  // Persist draft to localStorage
  useEffect(() => {
    const saved = localStorage.getItem("wizard-draft");
    if (saved) {
      form.reset(JSON.parse(saved));
    }
  }, []);

  useEffect(() => {
    const subscription = form.watch((data) => {
      localStorage.setItem("wizard-draft", JSON.stringify(data));
    });
    return () => subscription.unsubscribe();
  }, [form]);

  const handleNext = async () => {
    const isValid = await form.trigger();
    if (isValid && step < totalSteps) {
      goToStep(step + 1);
    }
  };

  const handleBack = () => {
    if (step > 1) goToStep(step - 1);
  };

  return (
    <FormProvider {...form}>
      {/* Progress indicator */}
      <div role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={totalSteps}>
        Step {step} of {totalSteps}
      </div>
      
      {/* Step heading - focused on navigation */}
      <h2 ref={headingRef} tabIndex={-1} className="outline-none">
        {step === 1 && "Personal Information"}
        {step === 2 && "Contact Details"}
        {step === 3 && "Address"}
      </h2>

      <form onSubmit={form.handleSubmit(onSubmit)}>
        {step === 1 && <StepOne />}
        {step === 2 && <StepTwo />}
        {step === 3 && <StepThree />}

        <div className="flex gap-4 mt-6">
          {step > 1 && (
            <button type="button" onClick={handleBack}>
              Back
            </button>
          )}
          {step < totalSteps ? (
            <button type="button" onClick={handleNext}>
              Continue
            </button>
          ) : (
            <button type="submit">Complete Registration</button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}
```

## 3. Backend Integration Patterns

### Server-Side Error Mapping

**Concept**: Map API validation errors back to specific form fields.

```tsx
import { useForm, UseFormReturn } from "react-hook-form";

interface APIError {
  field: string;
  message: string;
}

interface APIResponse {
  success: boolean;
  errors?: APIError[];
}

// Usage: pass form instance and call inside component
function useServerErrorHandler<T extends Record<string, unknown>>(form: UseFormReturn<T>) {
  return async (data: T) => {
    const response = await fetch("/api/register", {
      method: "POST",
      body: JSON.stringify(data),
    });

    const result: APIResponse = await response.json();

    if (!result.success && result.errors) {
      // Map server errors to form fields
      result.errors.forEach((error) => {
        form.setError(error.field as keyof T & string, {
          type: "server",
          message: error.message,
        });
      });

      // Focus the first errored field
      const firstErrorField = result.errors[0]?.field;
      if (firstErrorField) {
        form.setFocus(firstErrorField as keyof T & string);
      }
      return;
    }

    // Success handling
  };
}

// For nested errors (e.g., "address.city")
function mapNestedError(form: UseFormReturn, path: string, message: string) {
  form.setError(path as any, { type: "server", message });
}
```

### Debounced Async Validation

**Concept**: Validate expensive fields (username availability) without API overload.

```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import debounce from "lodash.debounce";

// Custom hook for async field validation
// Uses ref for validateFn to keep debounce stable and avoid timer resets.
// Cancels in-flight debounced calls on unmount to prevent memory leaks.
function useAsyncValidation<T>(
  validateFn: (value: T) => Promise<string | null>,
  delay = 500
) {
  const [isValidating, setIsValidating] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const validateFnRef = useRef(validateFn);
  validateFnRef.current = validateFn;

  const debouncedValidate = useMemo(
    () =>
      debounce(async (value: T) => {
        setIsValidating(true);
        try {
          const result = await validateFnRef.current(value);
          setError(result);
        } finally {
          setIsValidating(false);
        }
      }, delay),
    [delay]
  );

  // Cleanup debounce timer on unmount
  useEffect(() => () => debouncedValidate.cancel(), [debouncedValidate]);

  return { validate: debouncedValidate, isValidating, error };
}

// Usage with React Hook Form
// Validation runs in the onChange handler (not via effects) to avoid
// race conditions and unnecessary re-renders.
function UsernameField() {
  const { register, setError, clearErrors } = useFormContext();
  const [isChecking, setIsChecking] = useState(false);

  const checkUsername = async (value: string): Promise<string | null> => {
    if (!value || value.length < 3) return null;

    const response = await fetch(
      `/api/check-username?username=${encodeURIComponent(value)}`
    );
    const { available } = await response.json();
    return available ? null : "This username is already taken";
  };

  const { validate, isValidating } = useAsyncValidation(checkUsername);

  // Derive combined checking state
  const showChecking = isChecking || isValidating;

  const { onChange: rhfOnChange, ...rest } = register("username", {
    onChange: (e) => {
      const value = e.target.value;
      if (value && value.length >= 3) {
        setIsChecking(true);
        validate(value);
      } else {
        clearErrors("username");
      }
    },
    // Also validate via RHF's built-in async validate for submit-time
    validate: async (value) => {
      const result = await checkUsername(value);
      return result ?? true;
    },
  });

  return (
    <div>
      <input onChange={rhfOnChange} {...rest} />
      {showChecking && <span className="text-muted-foreground">Checking...</span>}
    </div>
  );
}
```

### Optimistic Updates

**Concept**: Show immediate feedback while the request is in flight.

```tsx
import { useTransition, useState } from "react";

type SubmitState = "idle" | "submitting" | "success" | "error";

function ProfileForm() {
  const [isPending, startTransition] = useTransition();
  const [submitState, setSubmitState] = useState<SubmitState>("idle");
  const [optimisticData, setOptimisticData] = useState<ProfileData | null>(null);

  async function handleSubmit(data: ProfileData) {
    // Immediately show optimistic update
    setOptimisticData(data);
    setSubmitState("submitting");

    startTransition(async () => {
      try {
        await updateProfile(data);
        setSubmitState("success");
        
        // Clear success state after delay
        setTimeout(() => setSubmitState("idle"), 2000);
      } catch (error) {
        // Revert optimistic update
        setOptimisticData(null);
        setSubmitState("error");
      }
    });
  }

  return (
    <form onSubmit={form.handleSubmit(handleSubmit)}>
      {/* Show optimistic preview */}
      {optimisticData && (
        <div className="opacity-70">
          Preview: {optimisticData.name}
        </div>
      )}

      <button type="submit" disabled={isPending}>
        {submitState === "submitting" && "Saving..."}
        {submitState === "success" && "Saved!"}
        {submitState === "error" && "Try Again"}
        {submitState === "idle" && "Save Changes"}
      </button>
    </form>
  );
}
```

## 4. Complex Component Patterns

### Accessible File Upload (Drag & Drop)

**Concept**: Drag-and-drop zones are often inaccessible. Ensure keyboard and screen reader support.

```tsx
import { useCallback, useId, useState, useRef } from "react";

interface FileUploadProps {
  accept?: string;
  maxSize?: number; // bytes
  onUpload: (files: File[]) => void;
}

export function AccessibleFileUpload({ accept, maxSize, onUpload }: FileUploadProps) {
  const [isDragOver, setIsDragOver] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const dropzoneId = useId();
  const errorId = `${dropzoneId}-error`;

  const handleFiles = useCallback((files: FileList | null) => {
    setError(null);
    if (!files?.length) return;

    const validFiles: File[] = [];
    Array.from(files).forEach((file) => {
      if (maxSize && file.size > maxSize) {
        setError(`${file.name} exceeds maximum size`);
        return;
      }
      validFiles.push(file);
    });

    if (validFiles.length) {
      onUpload(validFiles);
    }
  }, [maxSize, onUpload]);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setIsDragOver(false);
    handleFiles(e.dataTransfer.files);
  }, [handleFiles]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      inputRef.current?.click();
    }
  };

  return (
    <div>
      {/* Hidden but accessible file input */}
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={(e) => handleFiles(e.target.files)}
        className="sr-only"
        id={dropzoneId}
        aria-describedby={error ? errorId : undefined}
      />
      
      {/* Clickable and keyboard-accessible dropzone */}
      <label
        htmlFor={dropzoneId}
        role="button"
        tabIndex={0}
        onKeyDown={handleKeyDown}
        onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
        onDragLeave={() => setIsDragOver(false)}
        onDrop={handleDrop}
        className={cn(
          "flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer",
          "hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring",
          isDragOver && "border-primary bg-primary/5",
          error && "border-destructive"
        )}
      >
        <UploadIcon className="h-10 w-10 text-muted-foreground mb-2" />
        <span className="text-sm font-medium">
          Drop files here or click to browse
        </span>
        <span className="text-xs text-muted-foreground mt-1">
          {accept && `Accepted: ${accept}`}
          {maxSize && ` (Max: ${formatBytes(maxSize)})`}
        </span>
      </label>

      {error && (
        <p id={errorId} role="alert" className="text-sm text-destructive mt-2">
          {error}
        </p>
      )}
    </div>
  );
}
```

### Accessible Combobox (Searchable Select)

**Concept**: Native `<select>` is limited. Use a proper combobox pattern for search/filter.

```tsx
import { useState, useRef, useId, KeyboardEvent } from "react";

interface ComboboxOption {
  value: string;
  label: string;
}

interface ComboboxProps {
  options: ComboboxOption[];
  value?: string;
  onChange: (value: string) => void;
  placeholder?: string;
}

export function Combobox({ options, value, onChange, placeholder }: ComboboxProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  
  const inputId = useId();
  const listboxId = `${inputId}-listbox`;

  const filteredOptions = options.filter((opt) =>
    opt.label.toLowerCase().includes(query.toLowerCase())
  );

  const selectedOption = options.find((opt) => opt.value === value);

  const handleSelect = (option: ComboboxOption) => {
    onChange(option.value);
    setQuery("");
    setIsOpen(false);
    inputRef.current?.focus();
  };

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setActiveIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
        }
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((prev) => Math.max(prev - 1, 0));
        break;
      case "Enter":
        e.preventDefault();
        if (activeIndex >= 0 && filteredOptions[activeIndex]) {
          handleSelect(filteredOptions[activeIndex]);
        }
        break;
      case "Escape":
        setIsOpen(false);
        setQuery("");
        break;
    }
  };

  return (
    <div className="relative">
      <input
        ref={inputRef}
        id={inputId}
        type="text"
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-controls={listboxId}
        aria-activedescendant={activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined}
        aria-autocomplete="list"
        value={query || selectedOption?.label || ""}
        placeholder={placeholder}
        onChange={(e) => {
          setQuery(e.target.value);
          setIsOpen(true);
          setActiveIndex(-1);
        }}
        onFocus={() => setIsOpen(true)}
        onBlur={() => setTimeout(() => setIsOpen(false), 150)}
        onKeyDown={handleKeyDown}
        className="w-full px-3 py-2 border rounded-md"
      />

      {isOpen && filteredOptions.length > 0 && (
        <ul
          ref={listRef}
          id={listboxId}
          role="listbox"
          className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-auto"
        >
          {filteredOptions.map((option, index) => (
            <li
              key={option.value}
              id={`${listboxId}-option-${index}`}
              role="option"
              aria-selected={option.value === value}
              className={cn(
                "px-3 py-2 cursor-pointer",
                index === activeIndex && "bg-accent",
                option.value === value && "font-medium"
              )}
              onClick={() => handleSelect(option)}
              onMouseEnter={() => setActiveIndex(index)}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}

      {isOpen && filteredOptions.length === 0 && (
        <div className="absolute z-10 w-full mt-1 px-3 py-2 bg-background border rounded-md">
          No results found
        </div>
      )}
    </div>
  );
}
```

### Date Picker Strategy

**Concept**: Choose the right approach based on use case and accessibility needs.

```tsx
// OPTION 1: Native input (Best for mobile, simple use cases)
// Pros: Native a11y, mobile keyboards, no JS
// Cons: Limited styling, inconsistent across browsers
<input
  type="date"
  min="2024-01-01"
  max="2025-12-31"
  className="px-3 py-2 border rounded-md"
/>

// OPTION 2: Three separate selects (Best for birthdays, fixed ranges)
// Pros: Accessible, no calendar needed for known dates
// Cons: More inputs to manage
function BirthdatePicker({ value, onChange }: DatePickerProps) {
  const [month, day, year] = value ? value.split("-") : ["", "", ""];
  
  return (
    <fieldset>
      <legend className="text-sm font-medium mb-2">Date of Birth</legend>
      <div className="flex gap-2">
        <select
          aria-label="Month"
          value={month}
          onChange={(e) => onChange(`${e.target.value}-${day}-${year}`)}
        >
          <option value="">Month</option>
          {months.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
        </select>
        <select aria-label="Day" value={day} onChange={...}>
          <option value="">Day</option>
          {Array.from({ length: 31 }, (_, i) => (
            <option key={i + 1} value={String(i + 1).padStart(2, "0")}>{i + 1}</option>
          ))}
        </select>
        <select aria-label="Year" value={year} onChange={...}>
          <option value="">Year</option>
          {years.map((y) => <option key={y} value={y}>{y}</option>)}
        </select>
      </div>
    </fieldset>
  );
}

// OPTION 3: Calendar picker (For date ranges, scheduling)
// Use a tested library: react-day-picker, @radix-ui/react-calendar
// Key a11y requirements:
// - Arrow key navigation
// - Announce selected date to screen readers
// - Trap focus within calendar when open
// - Close on Escape
// See: https://react-day-picker.js.org/guides/accessibility
```

## 5. Accessibility Deep Dive

### Reduced Motion Support

**Concept**: Respect user preferences for reduced animations.

```tsx
// Hook to detect preference
function usePrefersReducedMotion() {
  // Lazy init: read actual value on first render to avoid animation flash
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
    typeof window !== "undefined"
      ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
      : false
  );

  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };
    query.addEventListener("change", handler);
    return () => query.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

// Usage in validation animations
function ErrorMessage({ message }: { message: string }) {
  const prefersReducedMotion = usePrefersReducedMotion();

  return (
    <p
      role="alert"
      className={cn(
        "text-sm text-destructive",
        !prefersReducedMotion && "animate-shake" // Only animate if allowed
      )}
    >
      {message}
    </p>
  );
}

// Tailwind config for reduced motion
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      keyframes: {
        shake: {
          "0%, 100%": { transform: "translateX(0)" },
          "25%": { transform: "translateX(-4px)" },
          "75%": { transform: "translateX(4px)" },
        },
      },
      animation: {
        shake: "shake 0.3s ease-in-out",
      },
    },
  },
};

// In CSS, use motion-safe/motion-reduce variants
// <div className="motion-safe:animate-shake motion-reduce:animate-none">
```

### Forced Colors (High Contrast Mode)

**Concept**: Windows High Contrast mode removes background colors. Borders become critical.

```tsx
// Problem: Red border for errors disappears in High Contrast mode
// Solution: Use forced-colors variant and ensure visible borders

<input
  className={cn(
    "border rounded-md",
    error && "border-destructive",
    // High contrast fallback - ensure border is always visible
    "forced-colors:border-[CanvasText]",
    error && "forced-colors:border-[Mark]" // System highlight color
  )}
/>

// For icons that convey meaning, ensure they have forced-colors support
<CheckIcon 
  className="text-success forced-colors:text-[Highlight]" 
  aria-hidden="true"
/>

// Error indicators need text backup, not just color
{error && (
  <span className="flex items-center gap-1 text-destructive">
    <AlertIcon className="h-4 w-4 forced-colors:text-[Mark]" aria-hidden="true" />
    <span>{error}</span> {/* Text is always readable */}
  </span>
)}
```

### Live Regions for Global Feedback

**Concept**: Announce form success/error to screen readers using aria-live regions.

```tsx
import { createContext, useContext, useState, useCallback } from "react";

interface Announcement {
  message: string;
  type: "polite" | "assertive";
}

const AnnouncerContext = createContext<{
  announce: (message: string, type?: "polite" | "assertive") => void;
} | null>(null);

// Provider component - add to app root
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
  const [announcement, setAnnouncement] = useState<Announcement | null>(null);

  const announce = useCallback((message: string, type: "polite" | "assertive" = "polite") => {
    // Clear first to ensure re-announcement of same message
    setAnnouncement(null);
    requestAnimationFrame(() => {
      setAnnouncement({ message, type });
    });
  }, []);

  return (
    <AnnouncerContext.Provider value={{ announce }}>
      {children}
      
      {/* Visually hidden live regions */}
      <div className="sr-only" aria-live="polite" aria-atomic="true">
        {announcement?.type === "polite" && announcement.message}
      </div>
      <div className="sr-only" aria-live="assertive" aria-atomic="true">
        {announcement?.type === "assertive" && announcement.message}
      </div>
    </AnnouncerContext.Provider>
  );
}

export function useAnnounce() {
  const context = useContext(AnnouncerContext);
  if (!context) throw new Error("useAnnounce must be used within AnnouncerProvider");
  return context.announce;
}

// Usage in form submission
function ContactForm() {
  const announce = useAnnounce();

  async function onSubmit(data: FormData) {
    try {
      await submitForm(data);
      announce("Form submitted successfully. We'll be in touch soon.", "polite");
    } catch (error) {
      announce("Form submission failed. Please check the errors and try again.", "assertive");
    }
  }
}

// Integration with Sonner/Radix Toast (ensure a11y)
import { toast } from "sonner";

// Sonner automatically handles aria-live, but verify:
toast.success("Profile updated", {
  description: "Your changes have been saved.",
  // Sonner uses role="status" which is aria-live="polite" by default
});

toast.error("Upload failed", {
  description: "The file was too large.",
  // For errors, consider if assertive is needed
});
```

## 6. Testing & Documentation

### Unit Testing with React Testing Library

**Concept**: Test user interactions, not implementation details.

```tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";

describe("ContactForm", () => {
  it("shows validation errors on submit with empty fields", async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    await user.click(screen.getByRole("button", { name: /send message/i }));

    expect(await screen.findByRole("alert")).toHaveTextContent(/email is required/i);
  });

  it("submits successfully with valid data", async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/email/i), "test@example.com");
    await user.type(screen.getByLabelText(/message/i), "Hello world");
    await user.click(screen.getByRole("button", { name: /send message/i }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: "test@example.com",
        message: "Hello world",
      });
    });
  });

  it("disables submit button while loading", async () => {
    const user = userEvent.setup();
    const slowSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));
    render(<ContactForm onSubmit={slowSubmit} />);

    await user.type(screen.getByLabelText(/email/i), "test@example.com");
    await user.type(screen.getByLabelText(/message/i), "Hello");
    
    const submitButton = screen.getByRole("button", { name: /send message/i });
    await user.click(submitButton);

    expect(submitButton).toBeDisabled();
    expect(submitButton).toHaveTextContent(/sending/i);
  });

  it("handles server errors gracefully", async () => {
    const user = userEvent.setup();
    const failingSubmit = vi.fn().mockRejectedValue(new Error("Server error"));
    render(<ContactForm onSubmit={failingSubmit} />);

    await user.type(screen.getByLabelText(/email/i), "test@example.com");
    await user.type(screen.getByLabelText(/message/i), "Hello");
    await user.click(screen.getByRole("button", { name: /send message/i }));

    expect(await screen.findByRole("alert")).toHaveTextContent(/something went wrong/i);
  });

  it("is keyboard accessible", async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    // Tab through all fields
    await user.tab();
    expect(screen.getByLabelText(/email/i)).toHaveFocus();
    
    await user.tab();
    expect(screen.getByLabelText(/message/i)).toHaveFocus();
    
    await user.tab();
    expect(screen.getByRole("button", { name: /send message/i })).toHaveFocus();
  });
});
```

### Storybook Documentation

**Concept**: Document all form component states for design system consistency.

```tsx
// SmartInput.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { SmartInput } from "./SmartInput";

const meta: Meta<typeof SmartInput> = {
  title: "Forms/SmartInput",
  component: SmartInput,
  parameters: {
    docs: {
      description: {
        component: "Accessible input component with built-in label, description, error handling, and password toggle.",
      },
    },
  },
  argTypes: {
    type: {
      control: "select",
      options: ["text", "email", "password", "tel", "url"],
    },
    error: { control: "text" },
    description: { control: "text" },
    isOptional: { control: "boolean" },
    disabled: { control: "boolean" },
  },
};

export default meta;
type Story = StoryObj<typeof SmartInput>;

export const Default: Story = {
  args: {
    label: "Email Address",
    placeholder: "you@example.com",
  },
};

export const WithDescription: Story = {
  args: {
    label: "Username",
    description: "This will be your public display name",
    placeholder: "johndoe",
  },
};

export const WithError: Story = {
  args: {
    label: "Email Address",
    error: "Please enter a valid email address",
    defaultValue: "invalid-email",
  },
};

export const Optional: Story = {
  args: {
    label: "Phone Number",
    isOptional: true,
    placeholder: "(555) 123-4567",
  },
};

export const Password: Story = {
  args: {
    label: "Password",
    type: "password",
    description: "Must be at least 8 characters",
  },
};

export const Disabled: Story = {
  args: {
    label: "Email Address",
    disabled: true,
    defaultValue: "disabled@example.com",
  },
};

export const Loading: Story = {
  render: () => (
    <div className="space-y-4">
      <SmartInput label="Username" />
      <p className="text-sm text-muted-foreground">Checking availability...</p>
    </div>
  ),
};

// Sizing variations for "Field Sizing as Affordance"
export const FieldSizes: Story = {
  render: () => (
    <div className="flex gap-4">
      <SmartInput label="CVV" widthClass="w-20" maxLength={4} />
      <SmartInput label="Zip Code" widthClass="w-28" />
      <SmartInput label="City" widthClass="w-48" />
    </div>
  ),
};

// Accessibility testing story
export const AccessibilityDemo: Story = {
  render: () => (
    <form className="space-y-4 max-w-md">
      <SmartInput label="Full Name" autoComplete="name" />
      <SmartInput label="Email" type="email" autoComplete="email" />
      <SmartInput 
        label="Password" 
        type="password" 
        autoComplete="new-password"
        description="Minimum 8 characters"
      />
      <SmartInput 
        label="Confirm Password" 
        type="password" 
        autoComplete="new-password"
        error="Passwords do not match"
      />
      <button type="submit" className="btn-primary">Create Account</button>
    </form>
  ),
  parameters: {
    a11y: {
      // Axe accessibility checks
      config: {
        rules: [
          { id: "color-contrast", enabled: true },
          { id: "label", enabled: true },
        ],
      },
    },
  },
};
```

## 7. Accessibility & Validation (Modern Stack)

### Integration with React Hook Form & Zod

Don't rely on browser defaults alone. Connect library state to ARIA attributes.

```tsx
// Example of connecting RHF errors to ARIA
<input
  {...register("email")}
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>;
{
  errors.email && (
    <span id="email-error" role="alert">
      {errors.email.message}
    </span>
  );
}
```

### Mobile Optimization

- **Input Modes**: Critical for triggering the right keyboard on iOS/Android.
  - Numbers (codes): `inputMode="numeric"` pattern="[0-9]\*"
  - Email: `inputMode="email"`
  - Search: `inputMode="search"` (adds "Go" button)
- **Touch Targets**: Min `44px` height (`h-11` in Tailwind default config usually works well).

## 8. Component Implementation Recipe

Here is a `shadcn/ui` style Field component that implements these principles automatically.

```tsx
import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";

// React 19: ref is a regular prop, no forwardRef needed
interface SmartInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  ref?: React.Ref<HTMLInputElement>;
  label: string;
  error?: string;
  description?: string;
  isOptional?: boolean;
  widthClass?: string; // For "Field Sizing as Affordance"
}

export const SmartInput = ({
  ref,
  label,
  error,
  description,
  isOptional,
  widthClass = "w-full",
  className,
  type = "text",
  ...props
}: SmartInputProps) => {
  const id = useId();
  const descriptionId = `${id}-desc`;
  const errorId = `${id}-error`;
  const [showPassword, setShowPassword] = useState(false);

  const isPassword = type === "password";
  const inputType = isPassword ? (showPassword ? "text" : "password") : type;

  return (
    <div className={cn("space-y-2", widthClass)}>
      <div className="flex justify-between items-baseline">
        <label
          htmlFor={id}
          className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
        >
          {label}
          {isOptional && (
            <span className="ml-2 text-muted-foreground font-normal text-xs">
              (Optional)
            </span>
          )}
        </label>
      </div>

      <div className="relative">
        <input
          ref={ref}
          id={id}
          type={inputType}
          className={cn(
            "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ri

…(truncated)
