Accessibility
Forms & Media
Accessible forms, error handling, validation, images, video, and audio
Forms & Media
Forms are the primary interaction point for users, and media must be perceivable to all. This covers building accessible forms and ensuring media content is inclusive.
Form Labels
Every form input must have a programmatically associated label.
Labeling Techniques
// 1. Explicit label — most reliable and recommended
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// 2. Wrapping label — label wraps the input
<label>
Email address
<input type="email" />
</label>
// 3. aria-label — when no visible label exists
<input type="search" aria-label="Search products" />
// 4. aria-labelledby — reference external text
<span id="qty-label">Quantity</span>
<input type="number" aria-labelledby="qty-label" />Always use visible labels. Placeholder text is not a substitute for labels — it disappears when the user starts typing, provides no persistent context, and has contrast issues.
Common Label Mistakes
// Bad — placeholder as label
<input type="email" placeholder="Email address" />
// Bad — hidden label with no alternative
<input type="text" />
// Bad — label not associated with input
<label>Email</label>
<input type="email" /> {/* Missing htmlFor/id connection */}
// Bad — same id for multiple inputs
<label htmlFor="name">First Name</label>
<input id="name" type="text" />
<label htmlFor="name">Last Name</label> {/* Duplicate! */}
<input id="name" type="text" />Form Structure
Grouping Related Fields
// Use fieldset and legend for related groups
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" type="text" autoComplete="street-address" />
<label htmlFor="city">City</label>
<input id="city" type="text" autoComplete="address-level2" />
<label htmlFor="zip">ZIP Code</label>
<input id="zip" type="text" autoComplete="postal-code" />
</fieldset>
// Radio buttons must be grouped
<fieldset>
<legend>Preferred contact method</legend>
<label>
<input type="radio" name="contact" value="email" /> Email
</label>
<label>
<input type="radio" name="contact" value="phone" /> Phone
</label>
<label>
<input type="radio" name="contact" value="mail" /> Mail
</label>
</fieldset>Autocomplete Attributes
Help users fill forms faster and reduce errors:
<form>
<input type="text" autoComplete="name" />
<input type="email" autoComplete="email" />
<input type="tel" autoComplete="tel" />
<input type="text" autoComplete="street-address" />
<input type="text" autoComplete="postal-code" />
<input type="password" autoComplete="current-password" />
<input type="password" autoComplete="new-password" />
<input type="text" autoComplete="one-time-code" />
</form>Required Fields
// Use required attribute + visual indicator
<label htmlFor="name">
Full Name <span aria-hidden="true">*</span>
</label>
<input id="name" type="text" required aria-required="true" />
// Explain the required indicator at the top of the form
<p>Fields marked with <span aria-hidden="true">*</span><span className="sr-only">asterisk</span> are required.</p>Error Handling and Validation
Inline Validation
function EmailField() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const errorId = useId();
const validate = (value: string) => {
if (!value) {
setError('Email is required.');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setError('Enter a valid email address.');
} else {
setError('');
}
};
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => validate(e.target.value)}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
aria-required="true"
/>
{error && (
<p id={errorId} role="alert" className="error-message">
{error}
</p>
)}
</div>
);
}Error Summary
For complex forms, provide an error summary at the top linking to each field with an error.
function ErrorSummary({ errors }: { errors: { field: string; id: string; message: string }[] }) {
const summaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (errors.length > 0) {
summaryRef.current?.focus();
}
}, [errors]);
if (errors.length === 0) return null;
return (
<div ref={summaryRef} role="alert" tabIndex={-1} className="error-summary">
<h2>There {errors.length === 1 ? 'is 1 error' : `are ${errors.length} errors`} in your form</h2>
<ul>
{errors.map((error) => (
<li key={error.field}>
<a href={`#${error.id}`}>{error.message}</a>
</li>
))}
</ul>
</div>
);
}Accessible Error Message Pattern
Form Validation Flow
├── User submits form
├── Validation errors found
│ ├── Focus moves to error summary (or first error field)
│ ├── Each error links to its field
│ ├── Fields show inline error messages
│ └── aria-invalid="true" set on error fields
└── Successful submission
└── Announce success via aria-live regionAccessible Custom Controls
Custom Checkbox
function Checkbox({ label, checked, onChange }: CheckboxProps) {
const inputId = useId();
return (
<div className="checkbox-wrapper">
<input
id={inputId}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only" // Hide native checkbox visually
/>
<label htmlFor={inputId} className="checkbox-label">
<span className={`checkbox-custom ${checked ? 'checked' : ''}`} aria-hidden="true">
{checked && <CheckIcon />}
</span>
{label}
</label>
</div>
);
}Custom Select / Dropdown
function Select({ label, options, value, onChange }: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const buttonId = useId();
const listboxId = useId();
const selected = options.find((o) => o.value === value);
return (
<div className="select-wrapper">
<label id={`${buttonId}-label`}>{label}</label>
<button
id={buttonId}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-labelledby={`${buttonId}-label`}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { setIsOpen(true); setActiveIndex(0); }
if (e.key === 'Escape') setIsOpen(false);
}}
>
{selected?.label ?? 'Select...'}
</button>
{isOpen && (
<ul role="listbox" id={listboxId} aria-labelledby={`${buttonId}-label`}>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={option.value === value}
className={index === activeIndex ? 'active' : ''}
onClick={() => { onChange(option.value); setIsOpen(false); }}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}Images
Alt Text Guidelines
// Informative image — describe the content
<img src="chart.png" alt="Bar chart showing 40% increase in sales from Q1 to Q4 2024" />
// Decorative image — empty alt
<img src="divider.png" alt="" />
// Functional image (inside a link/button) — describe the action
<a href="/">
<img src="logo.png" alt="Acme Corp — go to homepage" />
</a>
// Complex image — provide extended description
<figure>
<img src="architecture.png" alt="System architecture diagram" aria-describedby="arch-desc" />
<figcaption id="arch-desc">
The system consists of three layers: a React frontend communicates with
a Node.js API gateway, which routes requests to microservices backed
by PostgreSQL and Redis.
</figcaption>
</figure>
// SVG icons
<svg role="img" aria-label="Warning">
<title>Warning</title>
<path d="..." />
</svg>
// Decorative SVG
<svg aria-hidden="true" focusable="false">
<path d="..." />
</svg>| Image Type | Alt Text Strategy |
|---|---|
| Informative | Describe the content and purpose |
| Decorative | alt="" or aria-hidden="true" |
| Functional (in link/button) | Describe the action or destination |
| Text in image | Repeat the text in alt |
| Complex (charts, diagrams) | Short alt + aria-describedby for detailed description |
| Background image with meaning | Add hidden text alternative or use ARIA |
Video and Audio
Video Accessibility Requirements
<!-- Video with captions and audio description -->
<video controls>
<source src="demo.mp4" type="video/mp4" />
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
<track kind="descriptions" src="descriptions-en.vtt" srclang="en" label="Audio descriptions" />
<track kind="chapters" src="chapters-en.vtt" srclang="en" label="Chapters" />
<!-- Fallback for no video support -->
<p>Your browser does not support video. <a href="demo.mp4">Download the video</a>.</p>
</video>WCAG Media Requirements
| Requirement | Level | Description |
|---|---|---|
| Captions (prerecorded) | A | Synchronized text for audio content |
| Audio descriptions (prerecorded) | A | Narration of visual-only information |
| Captions (live) | AA | Real-time captions for live content |
| Sign language | AAA | Sign language interpretation |
| Transcript | A | Full text alternative for audio-only |
WebVTT Caption Format
WEBVTT
00:00:01.000 --> 00:00:04.000
Welcome to our product demo.
00:00:04.500 --> 00:00:08.000
Today we'll walk through the new dashboard features.
00:00:08.500 --> 00:00:12.000
[Screen shows the main dashboard with three panels]Accessible Media Player
function VideoPlayer({ src, captions }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
return (
<div role="region" aria-label="Video player">
<video ref={videoRef} src={src}>
{captions.map((track) => (
<track
key={track.lang}
kind="captions"
src={track.src}
srcLang={track.lang}
label={track.label}
default={track.default}
/>
))}
</video>
<div role="toolbar" aria-label="Video controls">
<button
onClick={() => {
isPlaying ? videoRef.current?.pause() : videoRef.current?.play();
setIsPlaying(!isPlaying);
}}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
onClick={() => {
const track = videoRef.current?.textTracks[0];
if (track) track.mode = track.mode === 'showing' ? 'hidden' : 'showing';
}}
aria-label="Toggle captions"
>
CC
</button>
</div>
</div>
);
}Best Practices
Forms & Media Guidelines
- Every input needs a visible, programmatically associated label
- Group related fields with
<fieldset>and<legend> - Use
aria-invalidandaria-describedbyfor error messages - Provide an error summary for complex forms and focus it on submit
- Use
autocompleteattributes for common fields - Write meaningful alt text — describe content, purpose, and context
- Provide captions for all video content and transcripts for audio
- Never use placeholder text as a substitute for labels