accessibility-wcag
Audit and fix web accessibility issues. Enforce WCAG 2.1 AA compliance, semantic HTML, ARIA patterns, keyboard navigation, screen reader compatibility.
Accessibility & WCAG Compliance
Audit web applications for WCAG 2.1/2.2 compliance, fix accessibility barriers, implement proper semantic HTML and ARIA patterns.
BAD: Common Accessibility Violations
// BAD: Div button without keyboard support
<div className="btn" onClick={handleClick}>
Submit
</div>
// BAD: Color-only error indication
<input className="border-red-500" />
<span style={{color: 'red'}}>Error</span>
// BAD: Image without alt text
<img src="/chart.png" />
// BAD: Aria-label overuse
<button aria-label="Click me">Click me</button>
// BAD: Auto-focus abuse
<input autoFocus />
<Modal open={isOpen}>
<input autoFocus /> {/* Steals focus */}
</Modal>
// BAD: No heading hierarchy
<h1>Page Title</h1>
<h4>Section</h4> {/* Skips h2, h3 */}
// BAD: Form without labels
<input type="text" placeholder="Email" />
<select>
<option>Choose...</option>
</select>
// BAD: Custom dropdown without keyboard support
<div onClick={toggle}>
{options.map(opt => <div onClick={() => select(opt)}>{opt}</div>)}
</div>
GOOD: Accessible Implementations
// GOOD: Semantic button with proper keyboard support
<button type="button" onClick={handleClick}>
Submit
</button>
// GOOD: Multi-sensory error indication
<input
className="border-red-500"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" className="text-red-600" role="alert">
<span className="sr-only">Error:</span>
Invalid email format
</span>
// GOOD: Descriptive alt text (or empty for decorative)
<img src="/sales-chart.png" alt="Sales increased 40% in Q4" />
<img src="/decorative-line.svg" alt="" role="presentation" />
// GOOD: Redundant aria-label removed
<button>Click me</button>
// GOOD: Managed focus with trap
function Modal({ open, onClose, children }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const previousFocus = document.activeElement as HTMLElement;
modalRef.current?.focus();
return () => previousFocus?.focus();
}, [open]);
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
{children}
</div>
);
}
// GOOD: Proper heading hierarchy
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
// GOOD: Labeled form controls
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-hint"
/>
<span id="email-hint" className="text-gray-600">
We'll never share your email
</span>
<label htmlFor="country">Country</label>
<select id="country" aria-required="true">
<option value="">Select a country</option>
<option value="us">United States</option>
</select>
// GOOD: Accessible custom dropdown
function Dropdown({ options, value, onChange, label }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => Math.min(prev + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
onChange(options[activeIndex]);
setOpen(false);
buttonRef.current?.focus();
break;
case 'Escape':
setOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div>
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-labelledby="dropdown-label"
onClick={() => setOpen(!open)}
>
<span id="dropdown-label">{label}</span>: {value}
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((opt, i) => (
<li
key={opt}
role="option"
aria-selected={i === activeIndex}
onClick={() => {
onChange(opt);
setOpen(false);
buttonRef.current?.focus();
}}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}
Keyboard Navigation Patterns
// Roving tabindex for toolbar
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let newIndex = index;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % items.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + items.length) % items.length;
} else if (e.key === 'Home') {
newIndex = 0;
} else if (e.key === 'End') {
newIndex = items.length - 1;
} else {
return;
}
e.preventDefault();
setFocusedIndex(newIndex);
};
return (
<div role="toolbar" aria-label="Formatting tools">
{items.map((item, i) => (
<button
key={item.id}
type="button"
tabIndex={i === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, i)}
onFocus={() => setFocusedIndex(i)}
>
{item.label}
</button>
))}
</div>
);
}
// Skip navigation link
function Layout({ children }) {
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white"
>
Skip to main content
</a>
<nav aria-label="Main navigation">
{/* Nav items */}
</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}
// Focus-visible styling (CSS)
/* Show focus only for keyboard users */
button:focus {
outline: none;
}
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Live Regions and Dynamic Content
// Status announcements
function Toast({ message, type }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="toast"
>
{message}
</div>
);
}
// Urgent alerts
function ErrorAlert({ message }) {
return (
<div
role="alert"
aria-live="assertive"
className="bg-red-100 border border-red-600 p-4"
>
<strong>Error:</strong> {message}
</div>
);
}
// Loading state announcement
function DataTable({ loading, data }) {
return (
<div>
{loading && (
<div role="status" aria-live="polite">
<span className="sr-only">Loading data...</span>
<div className="spinner" aria-hidden="true" />
</div>
)}
<table>
<caption className="sr-only">Sales data for 2025</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id}>
<th scope="row">{row.month}</th>
<td>{row.revenue}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Color Contrast and Motion
/* GOOD: WCAG AA contrast ratios */
.text-normal {
color: #1a1a1a; /* 4.5:1 on white */
background: #ffffff;
}
.text-large {
font-size: 18px;
font-weight: 700;
color: #4a4a4a; /* 3:1 on white (large text) */
}
.button-primary {
background: #0066cc; /* 4.5:1 with white text */
color: #ffffff;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* GOOD: Graceful animation degradation */
@media (prefers-reduced-motion: no-preference) {
.fade-in {
animation: fadeIn 0.3s ease-in;
}
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
opacity: 1; /* Skip animation, show immediately */
}
}
Automated Testing
// axe-core integration (Jest + React Testing Library)
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('LoginForm has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Playwright accessibility audit
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('Homepage should not have accessibility violations', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
// Cypress accessibility testing
describe('Accessibility', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('Has no detectable accessibility violations', () => {
cy.checkA11y();
});
it('Has no violations on specific component', () => {
cy.checkA11y('.navigation-menu');
});
it('Respects wcag2aa standard', () => {
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2aa']
}
});
});
});
Workflow
When auditing accessibility:
- Run automated tools first: axe DevTools, Lighthouse, WAVE
- Check keyboard navigation: Tab through entire page, verify focus order
- Test with screen reader: NVDA (Windows), VoiceOver (Mac), JAWS
- Verify color contrast: Use contrast checker for all text/UI elements
- Validate ARIA: Ensure roles/states match component behavior
- Test dynamic content: Live regions announce properly
- Check form accessibility: Labels, errors, validation messages
- Verify responsive behavior: Touch targets >= 44x44px on mobile
Common WCAG 2.1 Level AA failures:
- Missing alt text (1.1.1)
- Insufficient contrast (1.4.3)
- No keyboard access (2.1.1)
- Missing form labels (3.3.2)
- Invalid HTML/ARIA (4.1.1, 4.1.2)
Fix priority: Critical (blocks screen readers) > High (keyboard issues) > Medium (contrast) > Low (enhancements).