Web Accessibility (A11y)
When to use this skill
- New UI Component Development: Designing accessible components
- Accessibility Audit: Identifying and fixing accessibility issues in existing sites
- Form Implementation: Writing screen reader-friendly forms
- Modals/Dropdowns: Focus management and keyboard trap prevention
- WCAG Compliance: Meeting legal requirements or standards
Input Format
Required Information
- Framework: React, Vue, Svelte, Vanilla JS, etc.
- Component Type: Button, Form, Modal, Dropdown, Navigation, etc.
- WCAG Level: A, AA, AAA (default: AA)
Optional Information
- Screen Reader: NVDA, JAWS, VoiceOver (for testing)
- Automated Testing Tool: axe-core, Pa11y, Lighthouse (default: axe-core)
- Browser: Chrome, Firefox, Safari (default: Chrome)
Input Example
Make a React modal component accessible:
- Framework: React + TypeScript
- WCAG Level: AA
- Requirements:
- Focus trap (focus stays inside the modal)
- Close with ESC key
- Close by clicking the background
- Title/description read by screen readers
Instructions
Step 1: Use Semantic HTML
Use meaningful HTML elements to make the structure clear.
Tasks:
- Use semantic tags:
<button>,<nav>,<main>,<header>,<footer>, etc. - Avoid overusing
<div>and<span> - Use heading hierarchy (
<h1>~<h6>) correctly - Connect
<label>with<input>
Example (❌ Bad vs ✅ Good):
<!-- ❌ Bad example: using only div and span -->
<div class="header">
<span class="title">My App</span>
<div class="nav">
<div class="nav-item" onclick="navigate()">Home</div>
<div class="nav-item" onclick="navigate()">About</div>
</div>
</div>
<!-- ✅ Good example: semantic HTML -->
<header>
<h1>My App</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
Form Example:
<!-- ❌ Bad example: no label -->
<input type="text" placeholder="Enter your name">
<!-- ✅ Good example: label connected -->
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<!-- Or wrap with label -->
<label>
Email:
<input type="email" name="email" required>
</label>
Step 2: Implement Keyboard Navigation
Ensure all features are usable without a mouse.
Tasks:
- Move focus with Tab and Shift+Tab
- Activate buttons with Enter/Space
- Navigate lists/menus with arrow keys
- Close modals/dropdowns with ESC
- Use
tabindexappropriately
Decision Criteria:
- Interactive elements →
tabindex="0"(focusable) - Exclude from focus order →
tabindex="-1"(programmatic focus only) - Do not change focus order → avoid using
tabindex="1+"
Example (React Dropdown):
import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}
function AccessibleDropdown({ label, options, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Keyboard handler
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev + 1) % options.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) {
onChange(options[selectedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
>
{label}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === selectedIndex}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Step 3: Add ARIA Attributes
Provide additional context for screen readers.
Tasks:
aria-label: Define the element's namearia-labelledby: Reference another element as a labelaria-describedby: Provide additional descriptionaria-live: Announce dynamic content changesaria-hidden: Hide from screen readers
Checklist:
- All interactive elements have clear labels
- Button purpose is clear (e.g., "Submit form" not "Click")
- State change announcements (aria-live)
- Decorative images use alt="" or aria-hidden="true"
Example (Modal):
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// Focus trap when modal opens
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
>
<div className="modal-overlay" onClick={onClose} aria-hidden="true" />
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
<div id="modal-description">
{children}
</div>
<button onClick={onClose} aria-label="Close modal">
<span aria-hidden="true">×</span>
</button>
</div>
</div>
);
}
aria-live Example (Notifications):
function Notification({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
role="alert"
aria-live="assertive" // Immediate announcement (error), "polite" announces in turn
aria-atomic="true" // Read the entire content
className={`notification notification-${type}`}
>
{type === 'error' && <span aria-label="Error">⚠️</span>}
{type === 'success' && <span aria-label="Success">✅</span>}
{message}
</div>
);
}
Step 4: Color Contrast and Visual Accessibility
Ensure sufficient contrast ratios for users with visual impairments.
Tasks:
- WCAG AA: text 4.5:1, large text 3:1
- WCAG AAA: text 7:1, large text 4.5:1
- Do not convey information by color alone (use icons, patterns alongside)
- Clearly indicate focus (outline)
Example (CSS):
/* ✅ Sufficient contrast (text #000 on #FFF = 21:1) */
.button {
background-color: #0066cc;
color: #ffffff; /* contrast ratio 7.7:1 */
}
/* ✅ Focus indicator */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* ❌ outline: none is forbidden! */
button:focus {
outline: none; /* Never use this */
}
/* ✅ Indicate state with color + icon */
.error-message {
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.error-message::before {
content: '⚠️';
margin-right: 8px;
}
Step 5: Accessibility Testing
Validate accessibility with automated and manual testing.
Tasks:
- Automated scan with axe DevTools
- Check Lighthouse Accessibility score
- Test all features with keyboard only
- Screen reader testing (NVDA, VoiceOver)
Example (Jest + axe-core):
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';
expect.extend(toHaveNoViolations);
describe('AccessibleButton', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<AccessibleButton onClick={() => {}}>
Click Me
</AccessibleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard accessible', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<AccessibleButton onClick={handleClick}>
Click Me
</AccessibleButton>
);
const button = getByRole('button');
// Enter key
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
// Space key
fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
Output format
Basic Checklist
## Accessibility Checklist
### Semantic HTML
- [x] Use semantic HTML tags (`<button>`, `<nav>`, `<main>`, etc.)
- [x] Heading hierarchy is correct (h1 → h2 → h3)
- [x] All form labels are connected
### Keyboard Navigation
- [x] All interactive elements accessible via Tab
- [x] Buttons activated with Enter/Space
- [x] Modals/dropdowns closed with ESC
- [x] Focus indicator is clear (outline)
### ARIA
- [x] `role` used appropriately
- [x] `aria-label` or `aria-labelledby` provided
- [x] `aria-live` used for dynamic content
- [x] Decorative elements use `aria-hidden="true"`
### Visual
- [x] Color contrast meets WCAG AA (4.5:1)
- [x] Information not conveyed by color alone
- [x] Text size can be adjusted
- [x] Responsive design
### Testing
- [x] 0 axe DevTools violations
- [x] Lighthouse Accessibility score 90+
- [x] Keyboard test passed
- [x] Screen reader test completed
Constraints
Mandatory Rules (MUST)
-
Keyboard Accessibility: All features must be usable without a mouse
- Support Tab, Enter, Space, arrow keys, and ESC
- Implement focus trap (for modals)
-
Alternative Text: All images must have an
altattribute- Meaningful images: descriptive alt text
- Decorative images:
alt=""(screen reader ignores)
-
Clear Labels: All form inputs must have an associated label
<label for="...">oraria-label- Do not use placeholder alone as a substitute for a label
Prohibited Actions (MUST NOT)
-
Do Not Remove Outline: Never use
outline: none- Disastrous for keyboard users
- Must provide a custom focus style instead
-
Do Not Use tabindex > 0: Avoid changing focus order
- Keep DOM order logical
- Exception: only when there is a special reason
-
Do Not Convey Information by Color Alone: Accompany with icons or text
- Consider users with color blindness
- e.g., "Click red item" → "Click ⚠️ Error item"
Examples
Example 1: Accessible Form
function AccessibleContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
return (
<form onSubmit={handleSubmit} noValidate>
<h2 id="form-title">Contact Us</h2>
<p id="form-description">Please fill out the form below to get in touch.</p>
{/* Name */}
<div className="form-group">
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert" className="error">
{errors.name}
</span>
)}
</div>
{/* Email */}
<div className="form-group">
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint" className="hint">
We'll never share your email.
</span>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Submit button */}
<button type="submit" disabled={submitStatus === 'loading'}>
{submitStatus === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{/* Success/failure messages */}
{submitStatus === 'success' && (
<div role="alert" aria-live="polite" className="success">
✅ Form submitted successfully!
</div>
)}
{submitStatus === 'error' && (
<div role="alert" aria-live="assertive" className="error">
⚠️ An error occurred. Please try again.
</div>
)}
</form>
);
}
Example 2: Accessible Tab UI
function AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveTab((index + 1) % tabs.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveTab((index - 1 + tabs.length) % tabs.length);
break;
case 'Home':
e.preventDefault();
setActiveTab(0);
break;
case 'End':
e.preventDefault();
setActiveTab(tabs.length - 1);
break;
}
};
return (
<div>
{/* Tab List */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
Best practices
-
Semantic HTML First: ARIA is a last resort
- Using the correct HTML element makes ARIA unnecessary
- e.g.,
<button>vs<div role="button">
-
Focus Management: Manage focus on page transitions in SPAs
- Move focus to main content on new page load
- Provide skip links ("Skip to main content")
-
Error Messages: Clear and helpful error messages
- "Invalid input" ❌ → "Email must be in format: example@domain.com" ✅
References
Metadata
Version
- Current Version: 1.0.0
- Last Updated: 2025-01-01
- Compatible Platforms: Claude, ChatGPT, Gemini
Related Skills
- ui-component-patterns: UI component implementation
- responsive-design: Responsive design
Tags
#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend