PatternsAccessibility
Accessible Forms Advanced
Build complex, multi-step, and conditional forms that work for everyone
Forms are the backbone of web interactions. Complex forms with validation, multi-step flows, and conditional fields require careful accessibility implementation.
Form Structure Basics
// ✅ GOOD: Proper structure
export function AccessibleForm() {
return (
<form onSubmit={handleSubmit}>
{/* Each field in fieldset if grouped */}
<fieldset>
<legend>Personal Information</legend>
<div>
<label htmlFor="name">
Full Name <span aria-label="required">*</span>
</label>
<input
id="name"
name="name"
type="text"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<div id="name-error" role="alert">
{errors.name}
</div>
)}
</div>
</fieldset>
<button type="submit">Submit</button>
</form>
);
}Field Validation
Inline Validation
export function EmailField() {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const [touched, setTouched] = useState(false);
const validate = (email: string) => {
if (!email) {
return 'Email is required';
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return 'Please enter a valid email';
}
return '';
};
const handleBlur = () => {
setTouched(true);
setError(validate(value));
};
return (
<div>
<label htmlFor="email">
Email Address <abbr title="required" aria-label="required">*</abbr>
</label>
<input
id="email"
type="email"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
required
aria-required="true"
aria-invalid={touched && !!error}
aria-describedby={`email-hint ${error ? 'email-error' : ''}`}
/>
<div id="email-hint" className="hint">
We'll never share your email
</div>
{touched && error && (
<div id="email-error" role="alert" className="error">
{error}
</div>
)}
</div>
);
}Form-Level Validation
export function FormWithValidation() {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors = validateForm(formData);
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
// Focus error summary
errorSummaryRef.current?.focus();
// Announce errors
const errorCount = Object.keys(newErrors).length;
announceToScreenReader(
`Form has ${errorCount} error${errorCount > 1 ? 's' : ''}. Please fix them and try again.`
);
return;
}
setIsSubmitting(true);
await submitForm(formData);
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error Summary */}
{Object.keys(errors).length > 0 && (
<div
ref={errorSummaryRef}
role="alert"
aria-labelledby="error-summary-title"
className="error-summary"
tabIndex={-1}
>
<h2 id="error-summary-title">There are {Object.keys(errors).length} errors</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
)}
{/* Form fields */}
<FormFields data={formData} errors={errors} onChange={setFormData} />
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}Multi-Step Forms
export function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({});
const stepHeaderRef = useRef<HTMLHeadingElement>(null);
const totalSteps = 3;
useEffect(() => {
// Focus step header on step change
stepHeaderRef.current?.focus();
// Announce step change
announceToScreenReader(`Step ${step} of ${totalSteps}`);
}, [step]);
const nextStep = () => {
if (step < totalSteps) {
setStep(step + 1);
window.scrollTo(0, 0);
}
};
const prevStep = () => {
if (step > 1) {
setStep(step - 1);
window.scrollTo(0, 0);
}
};
return (
<form>
{/* Progress Indicator */}
<div
role="progressbar"
aria-valuenow={step}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Step ${step} of ${totalSteps}`}
>
<div className="progress-bar">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={i < step ? 'completed' : i === step - 1 ? 'current' : 'future'}
aria-current={i === step - 1 ? 'step' : undefined}
>
{i + 1}
</div>
))}
</div>
</div>
{/* Step Content */}
<div role="group" aria-labelledby="step-title">
<h2 id="step-title" ref={stepHeaderRef} tabIndex={-1}>
{getStepTitle(step)}
</h2>
{step === 1 && <Step1 data={formData} onChange={setFormData} />}
{step === 2 && <Step2 data={formData} onChange={setFormData} />}
{step === 3 && <Step3 data={formData} onChange={setFormData} />}
</div>
{/* Navigation */}
<div className="form-navigation">
<button
type="button"
onClick={prevStep}
disabled={step === 1}
aria-label="Go to previous step"
>
Previous
</button>
{step < totalSteps ? (
<button
type="button"
onClick={nextStep}
aria-label="Go to next step"
>
Next
</button>
) : (
<button type="submit">Submit</button>
)}
</div>
</form>
);
}Conditional Fields
export function ConditionalForm() {
const [hasAccount, setHasAccount] = useState(false);
const [showDetails, setShowDetails] = useState(false);
return (
<form>
{/* Primary Question */}
<fieldset>
<legend>Do you have an existing account?</legend>
<div>
<input
id="has-account-yes"
type="radio"
name="hasAccount"
value="yes"
checked={hasAccount}
onChange={() => setHasAccount(true)}
aria-controls="account-details"
/>
<label htmlFor="has-account-yes">Yes</label>
</div>
<div>
<input
id="has-account-no"
type="radio"
name="hasAccount"
value="no"
checked={!hasAccount}
onChange={() => setHasAccount(false)}
/>
<label htmlFor="has-account-no">No</label>
</div>
</fieldset>
{/* Conditional Fields */}
{hasAccount && (
<div
id="account-details"
role="group"
aria-labelledby="account-details-title"
>
<h3 id="account-details-title">Account Details</h3>
<label htmlFor="username">Username</label>
<input id="username" type="text" required />
<label htmlFor="password">Password</label>
<input id="password" type="password" required />
</div>
)}
{!hasAccount && (
<div>
<p>You'll need to create an account first.</p>
<a href="/signup">Create Account</a>
</div>
)}
</form>
);
}File Upload
export function AccessibleFileUpload() {
const [files, setFiles] = useState<File[]>([]);
const [error, setError] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate
const maxSize = 5 * 1024 * 1024; // 5MB
const invalidFiles = selectedFiles.filter(f => f.size > maxSize);
if (invalidFiles.length > 0) {
setError(`Files must be under 5MB: ${invalidFiles.map(f => f.name).join(', ')}`);
return;
}
setFiles(selectedFiles);
setError('');
// Announce
announceToScreenReader(`${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''} selected`);
};
const removeFile = (index: number) => {
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
// Return focus to input
inputRef.current?.focus();
announceToScreenReader(`File removed. ${newFiles.length} file${newFiles.length !== 1 ? 's' : ''} remaining`);
};
return (
<div>
<label htmlFor="file-upload">
Upload Files
<span className="hint">(Max 5MB each)</span>
</label>
<input
ref={inputRef}
id="file-upload"
type="file"
multiple
accept=".pdf,.doc,.docx"
onChange={handleFileChange}
aria-describedby="file-hint file-error"
aria-invalid={!!error}
/>
<div id="file-hint" className="hint">
Accepted formats: PDF, DOC, DOCX
</div>
{error && (
<div id="file-error" role="alert" className="error">
{error}
</div>
)}
{/* File List */}
{files.length > 0 && (
<ul aria-label="Selected files">
{files.map((file, index) => (
<li key={`${file.name}-${index}`}>
<span>{file.name}</span>
<span className="sr-only">
{(file.size / 1024).toFixed(2)} KB
</span>
<button
type="button"
onClick={() => removeFile(index)}
aria-label={`Remove ${file.name}`}
>
✕
</button>
</li>
))}
</ul>
)}
</div>
);
}Date Picker
export function AccessibleDatePicker() {
const [date, setDate] = useState('');
const [showCalendar, setShowCalendar] = useState(false);
return (
<div>
<label htmlFor="date-input">Select Date</label>
<div className="date-picker">
<input
id="date-input"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
aria-describedby="date-hint"
/>
<button
type="button"
onClick={() => setShowCalendar(!showCalendar)}
aria-label="Toggle calendar"
aria-expanded={showCalendar}
aria-controls="calendar"
>
📅
</button>
</div>
<div id="date-hint" className="hint">
Format: MM/DD/YYYY
</div>
{/* Alternative: Use native date input for best accessibility */}
{/* Native date pickers are already accessible */}
</div>
);
}Autocomplete
export function AccessibleAutocomplete() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, -1));
break;
case 'Enter':
if (selectedIndex >= 0) {
e.preventDefault();
selectResult(results[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
};
const selectResult = (value: string) => {
setQuery(value);
setIsOpen(false);
setSelectedIndex(-1);
};
useEffect(() => {
if (query.length > 2) {
searchAPI(query).then(setResults);
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [query]);
return (
<div className="autocomplete">
<label htmlFor="search-input">Search</label>
<input
id="search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={isOpen}
aria-activedescendant={
selectedIndex >= 0 ? `result-${selectedIndex}` : undefined
}
/>
{isOpen && results.length > 0 && (
<ul
id="search-results"
role="listbox"
aria-label="Search results"
>
{results.map((result, index) => (
<li
key={index}
id={`result-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectResult(result)}
>
{result}
</li>
))}
</ul>
)}
{/* Announce results */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{isOpen && `${results.length} result${results.length !== 1 ? 's' : ''} available`}
</div>
</div>
);
}Best Practices
- Label everything: Every input needs a label
- Required indicators: Mark required fields clearly
- Error messages: Specific, helpful, announced
- Focus management: Focus errors, announce changes
- Keyboard navigation: Tab order, arrow keys
- ARIA attributes:
aria-invalid,aria-describedby - Validation timing: On blur, not every keystroke
- Error summary: List all errors at top
- Progress indicators: Show current step
- Success confirmation: Announce successful submission
Common Pitfalls
❌ Placeholder as label: Disappears on focus
✅ Use persistent labels
❌ No error messages: User confused
✅ Specific, actionable error messages
❌ Color-only errors: Red border only
✅ Icons, text, ARIA attributes
❌ Auto-focus on page load: Unexpected
✅ Focus errors after submit
❌ No keyboard support: Tab doesn't work
✅ Full keyboard navigation
Complex forms require careful accessibility—validate thoroughly, manage focus, and test with real assistive technologies!