Implement web accessibility (a11y) standards following WCAG 2.1 guidelines. Use when building accessible UIs, fixing accessibility issues, or ensuring compliance with disability standards. Handles ARIA attributes, keyboard navigation, screen readers, semantic HTML, and accessibility testing.
89
88%
Does it follow best practices?
Impact
93%
1.09xAverage score across 3 eval scenarios
Passed
No known issues
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 readersUse meaningful HTML elements to make the structure clear.
Tasks:
<button>, <nav>, <main>, <header>, <footer>, etc.<div> and <span><h1> ~ <h6>) correctly<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>Ensure all features are usable without a mouse.
Tasks:
tabindex appropriatelyDecision Criteria:
tabindex="0" (focusable)tabindex="-1" (programmatic focus only)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>
);
}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 readersChecklist:
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>
);
}Ensure sufficient contrast ratios for users with visual impairments.
Tasks:
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;
}Validate accessibility with automated and manual testing.
Tasks:
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);
});
});## 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 completedKeyboard Accessibility: All features must be usable without a mouse
Alternative Text: All images must have an alt attribute
alt="" (screen reader ignores)Clear Labels: All form inputs must have an associated label
<label for="..."> or aria-labelDo Not Remove Outline: Never use outline: none
Do Not Use tabindex > 0: Avoid changing focus order
Do Not Convey Information by Color Alone: Accompany with icons or text
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>
);
}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>
);
}Semantic HTML First: ARIA is a last resort
<button> vs <div role="button">Focus Management: Manage focus on page transitions in SPAs
Error Messages: Clear and helpful error messages
#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend
c033769
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.