安装
npx skills add https://github.com/alexeira/skills --skill better-forms
复制
Better Forms Guide
A collection of specific UX patterns, accessibility standards, and implementation techniques for modern web forms. This guide bridges the gap between raw HTML/CSS tips and component-based architectures (React, Tailwind, Headless UI).
1. High-Impact UX Patterns (The "Why" & "How")
Avoid "Dead Zones" in Lists
Concept
Small gaps between clickable list items create frustration.
Implementation (Tailwind)
Use a pseudo-element to expand the hit area without affecting layout.
// 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.
<
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.
// 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.
import
{
useRef
,
useState
,
useCallback
,
ClipboardEvent
,
KeyboardEvent
}
from
"react"
;
interface
OTPInputProps
{
length
?
:
number
;
onComplete
:
(
code
:
string
)
=>
void
;
}
export
function
OTPInput
(
{
length
=
6
,
onComplete
}
:
OTPInputProps
)
{
const
[
values
,
setValues
]
=
useState
<
string
[
]
>
(
Array
(
length
)
.
fill
(
""
)
)
;
const
inputRefs
=
useRef
<
(
HTMLInputElement
|
null
)
[
]
>
(
[
]
)
;
const
focusInput
=
useCallback
(
(
index
:
number
)
=>
{
const
clampedIndex
=
Math
.
max
(
0
,
Math
.
min
(
index
,
length
-
1
)
)
;
inputRefs
.
current
[
clampedIndex
]
?.
focus
(
)
;
}
,
[
length
]
)
;
const
handleChange
=
(
index
:
number
,
value
:
string
)
=>
{
if
(
!
/
^
\d
*
$
/
.
test
(
value
)
)
return
;
// Only digits
const
newValues
=
[
...
values
]
;
newValues
[
index
]
=
value
.
slice
(
-
1
)
;
// Take last digit only
setValues
(
newValues
)
;
if
(
value
&&
index
<
length
-
1
)
{
focusInput
(
index
+
1
)
;
}
const
code
=
newValues
.
join
(
""
)
;
if
(
code
.
length
===
length
)
{
onComplete
(
code
)
;
}
}
;
const
handleKeyDown
=
(
index
:
number
,
e
:
KeyboardEvent
<
HTMLInputElement
>
)
=>
{
switch
(
e
.
key
)
{
case
"Backspace"
:
if
(
!
values
[
index
]
&&
index
>
0
)
{
focusInput
(
index
-
1
)
;
}
break
;
case
"ArrowLeft"
:
e
.
preventDefault
(
)
;
focusInput
(
index
-
1
)
;
break
;
case
"ArrowRight"
:
e
.
preventDefault
(
)
;
focusInput
(
index
+
1
)
;
break
;
}
}
;
const
handlePaste
=
(
e
:
ClipboardEvent
)
=>
{
e
.
preventDefault
(
)
;
const
pastedData
=
e
.
clipboardData
.
getData
(
"text"
)
.
replace
(
/
\D
/
g
,
""
)
.
slice
(
0
,
length
)
;
if
(
pastedData
)
{
const
newValues
=
[
...
values
]
;
pastedData
.
split
(
""
)
.
forEach
(
(
char
,
i
)
=>
{
newValues
[
i
]
=
char
;
}
)
;
setValues
(
newValues
)
;
focusInput
(
pastedData
.
length
-
1
)
;
if
(
pastedData
.
length
===
length
)
{
onComplete
(
pastedData
)
;
}
}
}
;
return
(
<
div
className
=
"
flex gap-2
"
role
=
"
group
"
aria-label
=
"
Verification code
"
>
{
values
.
map
(
(
value
,
index
)
=>
(
<
input
key
=
{
index
}
ref
=
{
(
el
)
=>
{
inputRefs
.
current
[
index
]
=
el
;
}
}
type
=
"
text
"
inputMode
=
"
numeric
"
maxLength
=
{
1
}
value
=
{
value
}
onChange
=
{
(
e
)
=>
handleChange
(
index
,
e
.
target
.
value
)
}
onKeyDown
=
{
(
e
)
=>
handleKeyDown
(
index
,
e
)
}
onPaste
=
{
handlePaste
}
className
=
"
h-12 w-12 text-center text-lg font-semibold border rounded-md
focus:ring-2 focus:ring-ring focus:border-transparent
"
aria-label
=
{
`
Digit
${
index
+
1
}
of
${
length
}
`
}
/>
)
)
}
</
div
>
)
;
}
Unsaved Changes Protection
Concept
Prevent accidental data loss when navigating away from a dirty form.
Note (React 19)
Don't confuse
useFormState
from
react-hook-form
with React DOM's
useFormState
, which was renamed to
useActionState
in React 19.
Warning
Monkey-patching
router.push
is fragile and may break across Next.js versions. There is no stable API for intercepting App Router navigation. The
beforeunload
approach is the only reliable part. Consider using
onBeforePopState
(Pages Router) or a route change event listener if your framework supports it.
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.
import
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
{
useForm
,
FormProvider
}
from
"react-hook-form"
;
import
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
// Persist state to URL for refresh resilience
// Uses lazy state init to read from URL on first render (SSR-safe)
function
useStepFromURL
(
)
{
const
[
step
,
setStep
]
=
useState
(
(
)
=>
{
if
(
typeof
window
===
"undefined"
)
return
1
;
const
params
=
new
URLSearchParams
(
window
.
location
.
search
)
;
return
parseInt
(
params
.
get
(
"step"
)
??
"1"
,
10
)
;
}
)
;
const
goToStep
=
useCallback
(
(
newStep
:
number
)
=>
{
setStep
(
newStep
)
;
const
url
=
new
URL
(
window
.
location
.
href
)
;
url
.
searchParams
.
set
(
"step"
,
String
(
newStep
)
)
;
window
.
history
.
pushState
(
{
}
,
""
,
url
)
;
}
,
[
]
)
;
return
{
step
,
goToStep
}
;
}
// Focus management on step change
function
useStepFocus
(
step
:
number
)
{
const
headingRef
=
useRef
<
HTMLHeadingElement
>
(
null
)
;
useEffect
(
(
)
=>
{
// Focus the step heading for screen reader announcement
headingRef
.
current
?.
focus
(
)
;
}
,
[
step
]
)
;
return
headingRef
;
}
// Example multi-step form
interface
WizardFormData
{
// Step 1
firstName
:
string
;
lastName
:
string
;
// Step 2
email
:
string
;
phone
:
string
;
// Step 3
address
:
string
;
city
:
string
;
}
const
stepSchemas
=
{
1
:
z
.
object
(
{
firstName
:
z
.
string
(
)
.
min
(
1
)
,
lastName
:
z
.
string
(
)
.
min
(
1
)
}
)
,
2
:
z
.
object
(
{
email
:
z
.
string
(
)
.
email
(
)
,
phone
:
z
.
string
(
)
.
optional
(
)
}
)
,
3
:
z
.
object
(
{
address
:
z
.
string
(
)
.
min
(
1
)
,
city
:
z
.
string
(
)
.
min
(
1
)
}
)
,
}
;
export
function
WizardForm
(
)
{
const
{
step
,
goToStep
}
=
useStepFromURL
(
)
;
const
headingRef
=
useStepFocus
(
step
)
;
const
totalSteps
=
3
;
const
form
=
useForm
<
WizardFormData
>
(
{
resolver
:
zodResolver
(
stepSchemas
[
step
as
keyof
typeof
stepSchemas
]
)
,
mode
:
"onBlur"
,
}
)
;
// Persist draft to localStorage
useEffect
(
(
)
=>
{
const
saved
=
localStorage
.
getItem
(
"wizard-draft"
)
;
if
(
saved
)
{
form
.
reset
(
JSON
.
parse
(
saved
)
)
;
}
}
,
[
]
)
;
useEffect
(
(
)
=>
{
const
subscription
=
form
.
watch
(
(
data
)
=>
{
localStorage
.
setItem
(
"wizard-draft"
,
JSON
.
stringify
(
data
)
)
;
}
)
;
return
(
)
=>
subscription
.
unsubscribe
(
)
;
}
,
[
form
]
)
;
const
handleNext
=
async
(
)
=>
{
const
isValid
=
await
form
.
trigger
(
)
;
if
(
isValid
&&
step
<
totalSteps
)
{
goToStep
(
step
+
1
)
;
}
}
;
const
handleBack
=
(
)
=>
{
if
(
step
>
1
)
goToStep
(
step
-
1
)
;
}
;
return
(
<
FormProvider
{
...
form
}
>
{
/ Progress indicator /
}
<
div
role
=
"
progressbar
"
aria-valuenow
=
{
step
}
aria-valuemin
=
{
1
}
aria-valuemax
=
{
totalSteps
}
>
Step
{
step
}
of
{
totalSteps
}
</
div
>
{
/ Step heading - focused on navigation /
}
<
h2
ref
=
{
headingRef
}
tabIndex
=
{
-
1
}
className
=
"
outline-none
"
>
{
step
===
1
&&
"Personal Information"
}
{
step
===
2
&&
"Contact Details"
}
{
step
===
3
&&
"Address"
}
</
h2
>
<
form
onSubmit
=
{
form
.
handleSubmit
(
onSubmit
)
}
>
{
step
===
1
&&
<
StepOne
/>
}
{
step
===
2
&&
<
StepTwo
/>
}
{
step
===
3
&&
<
StepThree
/>
}
<
div
className
=
"
flex gap-4 mt-6
"
>
{
step
>
1
&&
(
<
button
type
=
"
button
"
onClick
=
{
handleBack
}
>
Back
</
button
>
)
}
{
step
<
totalSteps
?
(
<
button
type
=
"
button
"
onClick
=
{
handleNext
}
>
Continue
</
button
>
)
:
(
<
button
type
=
"
submit
"
>
Complete Registration
</
button
>
)
}
</
div
>
</
form
>
</
FormProvider
>
)
;
}
3. Backend Integration Patterns
Server-Side Error Mapping
Concept
Map API validation errors back to specific form fields.
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.
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
debounce
from
"lodash.debounce"
;
// Custom hook for async field validation
// Uses ref for validateFn to keep debounce stable and avoid timer resets.
// Cancels in-flight debounced calls on unmount to prevent memory leaks.
function
useAsyncValidation
<
T
>
(
validateFn
:
(
value
:
T
)
=>
Promise
<
string
|
null
>
,
delay
=
500
)
{
const
[
isValidating
,
setIsValidating
]
=
useState
(
false
)
;
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
)
;
const
validateFnRef
=
useRef
(
validateFn
)
;
validateFnRef
.
current
=
validateFn
;
const
debouncedValidate
=
useMemo
(
(
)
=>
debounce
(
async
(
value
:
T
)
=>
{
setIsValidating
(
true
)
;
try
{
const
result
=
await
validateFnRef
.
current
(
value
)
;
setError
(
result
)
;
}
finally
{
setIsValidating
(
false
)
;
}
}
,
delay
)
,
[
delay
]
)
;
// Cleanup debounce timer on unmount
useEffect
(
(
)
=>
(
)
=>
debouncedValidate
.
cancel
(
)
,
[
debouncedValidate
]
)
;
return
{
validate
:
debouncedValidate
,
isValidating
,
error
}
;
}
// Usage with React Hook Form
// Validation runs in the onChange handler (not via effects) to avoid
// race conditions and unnecessary re-renders.
function
UsernameField
(
)
{
const
{
register
,
setError
,
clearErrors
}
=
useFormContext
(
)
;
const
[
isChecking
,
setIsChecking
]
=
useState
(
false
)
;
const
checkUsername
=
async
(
value
:
string
)
:
Promise
<
string
|
null
>
=>
{
if
(
!
value
||
value
.
length
<
3
)
return
null
;
const
response
=
await
fetch
(
`
/api/check-username?username=
${
encodeURIComponent
(
value
)
}
`
)
;
const
{
available
}
=
await
response
.
json
(
)
;
return
available
?
null
:
"This username is already taken"
;
}
;
const
{
validate
,
isValidating
}
=
useAsyncValidation
(
checkUsername
)
;
// Derive combined checking state
const
showChecking
=
isChecking
||
isValidating
;
const
{
onChange
:
rhfOnChange
,
...
rest
}
=
register
(
"username"
,
{
onChange
:
(
e
)
=>
{
const
value
=
e
.
target
.
value
;
if
(
value
&&
value
.
length
>=
3
)
{
setIsChecking
(
true
)
;
validate
(
value
)
;
}
else
{
clearErrors
(
"username"
)
;
}
}
,
// Also validate via RHF's built-in async validate for submit-time
validate
:
async
(
value
)
=>
{
const
result
=
await
checkUsername
(
value
)
;
return
result
??
true
;
}
,
}
)
;
return
(
<
div
>
<
input
onChange
=
{
rhfOnChange
}
{
...
rest
}
/>
{
showChecking
&&
<
span
className
=
"
text-muted-foreground
"
>
Checking...
</
span
>
}
</
div
>
)
;
}
Optimistic Updates
Concept
Show immediate feedback while the request is in flight.
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.
import
{
useCallback
,
useId
,
useState
,
useRef
}
from
"react"
;
interface
FileUploadProps
{
accept
?
:
string
;
maxSize
?
:
number
;
// bytes
onUpload
:
(
files
:
File
[
]
)
=>
void
;
}
export
function
AccessibleFileUpload
(
{
accept
,
maxSize
,
onUpload
}
:
FileUploadProps
)
{
const
[
isDragOver
,
setIsDragOver
]
=
useState
(
false
)
;
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
)
;
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
)
;
const
dropzoneId
=
useId
(
)
;
const
errorId
=
`
${
dropzoneId
}
-error
`
;
const
handleFiles
=
useCallback
(
(
files
:
FileList
|
null
)
=>
{
setError
(
null
)
;
if
(
!
files
?.
length
)
return
;
const
validFiles
:
File
[
]
=
[
]
;
Array
.
from
(
files
)
.
forEach
(
(
file
)
=>
{
if
(
maxSize
&&
file
.
size
>
maxSize
)
{
setError
(
`
${
file
.
name
}
exceeds maximum size
`
)
;
return
;
}
validFiles
.
push
(
file
)
;
}
)
;
if
(
validFiles
.
length
)
{
onUpload
(
validFiles
)
;
}
}
,
[
maxSize
,
onUpload
]
)
;
const
handleDrop
=
useCallback
(
(
e
:
React
.
DragEvent
)
=>
{
e
.
preventDefault
(
)
;
setIsDragOver
(
false
)
;
handleFiles
(
e
.
dataTransfer
.
files
)
;
}
,
[
handleFiles
]
)
;
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
"Enter"
||
e
.
key
===
" "
)
{
e
.
preventDefault
(
)
;
inputRef
.
current
?.
click
(
)
;
}
}
;
return
(
<
div
>
{
/ Hidden but accessible file input /
}
<
input
ref
=
{
inputRef
}
type
=
"
file
"
accept
=
{
accept
}
onChange
=
{
(
e
)
=>
handleFiles
(
e
.
target
.
files
)
}
className
=
"
sr-only
"
id
=
{
dropzoneId
}
aria-describedby
=
{
error
?
errorId
:
undefined
}
/>
{
/ Clickable and keyboard-accessible dropzone /
}
<
label
htmlFor
=
{
dropzoneId
}
role
=
"
button
"
tabIndex
=
{
0
}
onKeyDown
=
{
handleKeyDown
}
onDragOver
=
{
(
e
)
=>
{
e
.
preventDefault
(
)
;
setIsDragOver
(
true
)
;
}
}
onDragLeave
=
{
(
)
=>
setIsDragOver
(
false
)
}
onDrop
=
{
handleDrop
}
className
=
{
cn
(
"flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer"
,
"hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
,
isDragOver
&&
"border-primary bg-primary/5"
,
error
&&
"border-destructive"
)
}
>
<
UploadIcon
className
=
"
h-10 w-10 text-muted-foreground mb-2
"
/>
<
span
className
=
"
text-sm font-medium
"
>
Drop files here or click to browse
</
span
>
<
span
className
=
"
text-xs text-muted-foreground mt-1
"
>
{
accept
&&
`
Accepted:
${
accept
}
`
}
{
maxSize
&&
`
(Max:
${
formatBytes
(
maxSize
)
}
)
`
}
</
span
>
</
label
>
{
error
&&
(
<
p
id
=
{
errorId
}
role
=
"
alert
"
className
=
"
text-sm text-destructive mt-2
"
>
{
error
}
</
p
>
)
}
</
div
>
)
;
}
Accessible Combobox (Searchable Select)
Concept
Native
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
"
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.
// 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.
// 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
//
Forced Colors (High Contrast Mode)
Concept
Windows High Contrast mode removes background colors. Borders become critical.
// 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.
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.
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.
// 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.
// 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.
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?
← 返回排行榜