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.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add Asymmetric-al/core --skill better-forms --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Better Forms?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/asymmetric-al-better-forms-core)More formats (shields.io, HTML) on the badges page.
---
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
---
> **Repo notice (Asymmetric-al/core):** This repository is **Base UI only**.
> Shared primitives come from `@base-ui/react` via the shadcn `base-maia`
> style in `packages/ui`. Ignore any Radix UI guidance below — never add
> `radix-ui`/`@radix-ui/*` imports or dependencies; composition uses Base
> UI's `render` prop, not `asChild`. See `docs/ai/rules/frontend.md`.
# 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 {
useEffect,
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)[]>([]);
// Keep state aligned with dynamic length changes.
useEffect(() => {
setValues((prev) => {
if (prev.length === length) return prev;
const next = prev.slice(0, length);
while (next.length < length) next.push("");
return next;
});
inputRefs.current = inputRefs.current.slice(0, length);
}, [length]);
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";
const MIN_WIZARD_STEP = 1;
const MAX_WIZARD_STEP = 3;
function clampStep(step: number) {
if (!Number.isFinite(step)) return MIN_WIZARD_STEP;
return Math.min(MAX_WIZARD_STEP, Math.max(MIN_WIZARD_STEP, step));
}
// 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 MIN_WIZARD_STEP;
const params = new URLSearchParams(window.location.search);
return clampStep(parseInt(params.get("step") ?? String(MIN_WIZARD_STEP), 10));
});
const goToStep = useCallback((newStep: number) => {
const clampedStep = clampStep(newStep);
setStep(clampedStep);
const url = new URL(window.location.href);
url.searchParams.set("step", String(clampedStep));
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 = MAX_WIZARD_STEP;
const canUseStorage =
typeof window !== "undefined" &&
typeof window.localStorage !== "undefined";
const form = useForm<WizardFormData>({
resolver: zodResolver(stepSchemas[step as keyof typeof stepSchemas]),
mode: "onBlur",
});
// Persist draft to localStorage
useEffect(() => {
if (!canUseStorage) return;
const saved = localStorage.getItem("wizard-draft");
if (saved) {
form.reset(JSON.parse(saved));
}
}, [canUseStorage, form]);
useEffect(() => {
if (!canUseStorage) return;
const subscription = form.watch((data) => {
localStorage.setItem("wizard-draft", JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [canUseStorage, form]);
const handleNext = async () => {
const isValid = await form.trigger();
if (isValid && step < totalSteps) {
goToStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) goToStep(step - 1);
};
const onSubmit = (data: WizardFormData) => {
console.log("Wizard submit", data);
if (canUseStorage) {
localStorage.removeItem("wizard-draft");
}
};
const StepOne = () => (
<div className="grid gap-4">
<label>
First name
<input {...form.register("firstName")} />
</label>
<label>
Last name
<input {...form.register("lastName")} />
</label>
</div>
);
const StepTwo = () => (
<div className="grid gap-4">
<label>
Email
<input type="email" {...form.register("email")} />
</label>
<label>
Phone (optional)
<input type="tel" {...form.register("phone")} />
</label>
</div>
);
const StepThree = () => (
<div className="grid gap-4">
<label>
Address
<input {...form.register("address")} />
</label>
<label>
City
<input {...form.register("city")} />
</label>
</div>
);
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,
formState: { errors },
} = useFormContext();
const [isChecking, setIsChecking] = useState(false);
const checkUsername = async (value: string): Promise<string | null> => {
if (!value || value.length < 3) return null;
try {
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(value)}`,
);
if (!response.ok) {
return "Unable to validate username right now";
}
const { available } = (await response.json()) as { available?: boolean };
if (typeof available !== "boolean") {
return "Unable to validate username right now";
}
return available ? null : "This username is already taken";
} catch {
return "Unable to validate username right now";
}
};
const { validate, isValidating, error } = useAsyncValidation(checkUsername);
// Derive combined checking state
const showChecking = isChecking || isValidating;
// Clear the optimistic checking flag when async validation settles.
useEffect(() => {
if (!isValidating) {
setIsChecking(false);
}
}, [isValidating]);
// Reflect async availability result into RHF field error state.
useEffect(() => {
if (isValidating) return;
if (error) {
setError("username", { type: "validate", message: error });
} else {
clearErrors("username");
}
}, [error, isValidating, setError, clearErrors]);
const { onChange: rhfOnChange, ...rest } = register("username", {
onChange: (e) => {
const value = e.target.value;
if (value && value.length >= 3) {
setIsChecking(true);
validate(value);
} else {
setIsChecking(false);
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>
)}
{errors.username?.message && (
<span className="text-destructive">
{String(errors.username.message)}
</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[] = [];
const oversizedFiles: string[] = [];
Array.from(files).forEach((file) => {
if (maxSize && file.size > maxSize) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (oversizedFiles.length) {
setError(
oversizedFiles.length === 1
? `${oversizedFiles[0]} exceeds maximum size`
: `${oversizedFiles.length} files exceed maximum size: ${oversizedFiles.join(", ")}`,
);
}
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:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className,
)}
aria-invalid={!!error}
aria-describedby={
[description && descriptionId, error && errorId]
.filter(Boolean)
.join(" ") || undefined
}
{...props}
/>
{/* Password Toggle Pattern */}
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
{/* Description linked via ARIA */}
{description && !error && (
<p id={descriptionId} className="text-sm text-muted-foreground">
{description}
</p>
)}
{/* Error Message with role="alert" */}
{error && (
<p
id={errorId}
role="alert"
className="text-sm font-medium text-destructive"
>
{error}
</p>
)}
</div>
);
};
```
## Checklist for Review
### Layout & UX
- [ ] **Single Column**: Is the form mostly single column? (Exceptions: City/Zip, First/Last Name)
- [ ] **Labels**: Are placeholder labels avoided? (Labels must remain visible while typing)
- [ ] **Buttons**: Does the submit button say what it does? (e.g. "Create Account" vs "Submit")
- [ ] **Affordance**: Are short inputs (CVV) actually short on screen?
- [ ] **Input Masking**: Are formatted fields (phone, credit card, currency) using proper masking?
- [ ] **Unsaved Changes**: Is navigation blocked when form has unsaved changes?
### Accessibility & Code
- [ ] **Focus**: Is the focus ring visible? (Tailwind `focus-visible:ring`)
- [ ] **Click Areas**: Do lists/radios have expanded hit areas? (`inset` tricks)
- [ ] **Semantics**: Are errors linked with `aria-describedby`?
- [ ] **Keyboard**: Can you fill the form without a mouse? (Check password toggles and custom sliders)
- [ ] **Reduced Motion**: Are animations disabled for `prefers-reduced-motion`?
- [ ] **High Contrast**: Do error states work in Windows High Contrast mode? (`forced-colors:`)
- [ ] **Live Regions**: Are success/error messages announced to screen readers?
- [ ] **File Uploads**: Is the drag-and-drop zone keyboard accessible?
### Performance & Backend
- [ ] **Validation**: Is heavy validation (API checks) debounced?
- [ ] **Submission**: Does the button show a loading state and disable to prevent double-submit?
- [ ] **Server Errors**: Are backend validation errors mapped to specific form fields?
- [ ] **Optimistic UI**: Does the form show immediate feedback during submission?
### Testing & Documentation
- [ ] **Unit Tests**: Are critical paths tested with React Testing Library?
- [ ] **Storybook**: Are all component states documented (default, error, loading, disabled)?
- [ ] **A11y Tests**: Are axe or similar tools integrated in tests/Storybook?
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!