--- name: better-forms description: 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. version: 2.1.0 --- # 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
``` ### 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 ``` ### 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"; { // values.value = "1234567890" (raw) // values.formattedValue = "(123) 456-7890" form.setValue("phone", values.value); }} />; // Credit card with automatic spacing form.setValue("cardNumber", values.value)} />; // Currency input import { NumericFormat } from "react-number-format"; 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(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) => { 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 (
{values.map((value, index) => ( { 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}`} /> ))}
); } ``` ### 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(); const { isDirty } = useFormState({ control: form.control }); useUnsavedChangesWarning(isDirty); return
...
; } ``` ### 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(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({ 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 ( {/* Progress indicator */}
Step {step} of {totalSteps}
{/* Step heading - focused on navigation */}

{step === 1 && "Personal Information"} {step === 2 && "Contact Details"} {step === 3 && "Address"}

{step === 1 && } {step === 2 && } {step === 3 && }
{step > 1 && ( )} {step < totalSteps ? ( ) : ( )}
); } ``` ## 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>(form: UseFormReturn) { 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( validateFn: (value: T) => Promise, delay = 500 ) { const [isValidating, setIsValidating] = useState(false); const [error, setError] = useState(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 => { 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 (
{showChecking && Checking...}
); } ``` ### 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("idle"); const [optimisticData, setOptimisticData] = useState(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 (
{/* Show optimistic preview */} {optimisticData && (
Preview: {optimisticData.name}
)}
); } ``` ## 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(null); const inputRef = useRef(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 (
{/* Hidden but accessible file input */} handleFiles(e.target.files)} className="sr-only" id={dropzoneId} aria-describedby={error ? errorId : undefined} /> {/* Clickable and keyboard-accessible dropzone */} {error && ( )}
); } ``` ### Accessible Combobox (Searchable Select) **Concept**: Native `= 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 && (
    {filteredOptions.map((option, index) => (
  • handleSelect(option)} onMouseEnter={() => setActiveIndex(index)} > {option.label}
  • ))}
)} {isOpen && filteredOptions.length === 0 && (
No results found
)} ); } ``` ### 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 // 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 (
Date of Birth
); } // 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 (

{message}

); } // 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 //
``` ### 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 // For icons that convey meaning, ensure they have forced-colors support