Skip to main content Home Creators asymmetric-al core better-forms
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.
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/Asymmetric-al/core --skill better-formsThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository Pointer to the canonical agent instruction and skill system for this monorepo
Tasteful, subtle web UI animation following Emil Kowalski / animations.dev principles. Use when adding or reviewing interface motion — hover and press feedback, entrances and exits, modals, toasts, menus, loading and skeleton states, staggered reveals, page or view transitions — so motion stays refined and purposeful, not decorative. Covers CSS, Web Animations, and React timing/easing. Pair with the motion skill for motion/react API specifics.
Build correct cached/dynamic boundaries in the Next.js App Router when Cache Components or PPR are enabled. Use when working with cacheComponents, Partial Prerendering (PPR), 'use cache', cacheLife, cacheTag, updateTag, or revalidateTag, to avoid request-context leaks and enforce proper cache invalidation. Not for the Pages Router or when Cache Components/PPR are off.
Related occupations SOC
Based on SOC occupation classification
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.
<div className="relative group" >
<input type = = />
</div>
"radio"
className
"..."
<label className ="... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']" >
Option Label
</label >
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.
<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.
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);
}}
/> ;
<PatternFormat
format ="#### #### #### ####"
customInput ={Input}
onValueChange ={(values) => form.setValue("cardNumber", values.value)}
/> ;
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.
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 )[]>([]);
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 ;
const newValues = [...values];
newValues[index] = value.slice (-1 );
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.
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?" ;
useEffect (() => {
const handleBeforeUnload = (e : BeforeUnloadEvent ) => {
if (!isDirty) return ;
e.preventDefault ();
};
window .addEventListener ("beforeunload" , handleBeforeUnload);
return () => window .removeEventListener ("beforeunload" , handleBeforeUnload);
}, [isDirty]);
}
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.
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));
}
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 };
}
function useStepFocus (step : number ) {
const headingRef = useRef<HTMLHeadingElement >(null );
useEffect (() => {
headingRef.current ?.focus ();
}, [step]);
return headingRef;
}
interface WizardFormData {
firstName : string ;
lastName : string ;
email : string ;
phone ?: string ;
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" ,
});
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
) : (
Complete Registration
)}
);
}
3. Backend Integration Patterns
Server-Side Error Mapping Concept : Map API validation errors back to specific form fields.
import { useForm, UseFormReturn } from "react-hook-form" ;
interface APIError {
field : string ;
message : string ;
}
interface APIResponse {
success : boolean ;
errors ?: APIError [];
}
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 ) {
result.errors .forEach ((error ) => {
form.setError (error.field as keyof T & string , {
type : "server" ,
message : error.message ,
});
});
const firstErrorField = result.errors [0 ]?.field ;
if (firstErrorField) {
form.setFocus (firstErrorField as keyof T & string );
}
return ;
}
};
}
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.
import { useEffect, useMemo, useRef, useState } from "react" ;
import debounce from "lodash.debounce" ;
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],
);
useEffect (() => () => debouncedValidate.cancel (), [debouncedValidate]);
return { validate : debouncedValidate, isValidating, error };
}
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);
const showChecking = isChecking || isValidating;
useEffect (() => {
if (!isValidating) {
setIsChecking (false );
}
}, [isValidating]);
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" );
}
},
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.
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 ) {
setOptimisticData (data);
setSubmitState ("submitting" );
startTransition (async () => {
try {
await updateProfile (data);
setSubmitState ("success" );
setTimeout (() => setSubmitState ("idle" ), 2000 );
} catch (error) {
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.
import { useCallback, useId, useState, useRef } from "react" ;
interface FileUploadProps {
accept ?: string ;
maxSize ?: number ;
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 && (
{error}
)}
);
}
Accessible Combobox (Searchable Select) Concept : Native <select> is limited. Use a proper combobox pattern for search/filter.
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"
= ===
=
" ",
=== && " ",
=== && " ",
)}
= => 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.
<input
type ="date"
min="2024-01-01"
max="2025-12-31"
className="px-3 py-2 border rounded-md"
/>
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 >
);
}
5. Accessibility Deep Dive
Reduced Motion Support Concept : Respect user preferences for reduced animations.
function usePrefersReducedMotion ( ) {
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;
}
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 >
);
}
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" ,
},
},
},
};
Forced Colors (High Contrast Mode) Concept : Windows High Contrast mode removes background colors. Borders become critical.
<input
className={cn (
"border rounded-md" ,
error && "border-destructive" ,
"forced-colors:border-[CanvasText]" ,
error && "forced-colors:border-[Mark]"
)}
/>
<CheckIcon
className ="text-success forced-colors:text-[Highlight]"
aria-hidden ="true"
/>
{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.
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 );
export function AnnouncerProvider ({ children }: { children: React.ReactNode } ) {
const [announcement, setAnnouncement] = useState<Announcement | null >(null );
const announce = useCallback (
(message : string , type : "polite" | "assertive" = "polite" ) => {
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 ;
}
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" ,
);
}
}
}
import { toast } from "sonner" ;
toast.success ("Profile updated" , {
description : "Your changes have been saved." ,
});
toast.error ("Upload failed" , {
description : "The file was too large." ,
});
6. Testing & Documentation
Unit Testing with React Testing Library Concept : Test user interactions, not implementation details.
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 /> );
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.
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 >
),
};
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 >
),
};
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 : {
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.
<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.
import { useId, useState } from "react" ;
import { Eye , EyeOff } from "lucide-react" ;
import { cn } from "@/lib/utils" ;
interface SmartInputProps extends React .InputHTMLAttributes <HTMLInputElement > {
ref ?: React .Ref <HTMLInputElement >;
label : string ;
error ?: string ;
description ?: string ;
isOptional ?: boolean ;
widthClass ?: string ;
}
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 ",
&& " ",
,
)}
=
=
[ && , && ]
( )
(" ") ||
}
{ }
/>
{/* Password Toggle Pattern */}
{isPassword && (
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 ? : }
)}
{/* Description linked via ARIA */}
{description && !error && (
{description}
)}
{/* Error Message with role="alert" */}
{error && (
{error}
)}
);
};
Checklist for Review
Layout & UX
Accessibility & Code
Performance & Backend
Testing & Documentation </button >
<button type ="submit" >
</button >
</div >
</form >
</FormProvider >
<p id ={errorId} role ="alert" className ="text-sm text-destructive mt-2" >
</p >
</div >
aria-selected
{option.value
value}
className
{cn(
px-3
py-2
cursor-pointer
index
activeIndex
bg-accent
option.value
value
font-medium
onClick
{()
</li >
</ul >
<div className ="absolute z-10 w-full mt-1 px-3 py-2 bg-background border rounded-md" >
</div >
</div >
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
<button
type ="button"
onClick ={() =>
<EyeOff size ={16} />
<Eye size ={16} />
</button >
</div >
<p id ={descriptionId} className ="text-sm text-muted-foreground" >
</p >
<p
id ={errorId}
role ="alert"
className ="text-sm font-medium text-destructive"
>
</p >
</div >