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:
- [x] All interactive elements have clear labels
- [x] Button purpose is clear (e.g., "Submit form" not "Click")
- [x] State change announcements (aria-live)
- [x] 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 */
.