Accessibility Implementation Best Practices
1. WCAG 2.1 AA Semantic HTML5
| Element | Purpose | WCAG Guideline | Example |
|---|---|---|---|
| <header> | Page/section header | Info and Relationships (1.3.1) | Site banner, article header |
| <nav> | Navigation links | Info and Relationships (1.3.1) | Main menu, breadcrumbs |
| <main> | Primary content | Info and Relationships (1.3.1) | One per page, unique content |
| <article> | Self-contained content | Info and Relationships (1.3.1) | Blog post, news article |
| <aside> | Tangentially related content | Info and Relationships (1.3.1) | Sidebar, related links |
| <footer> | Page/section footer | Info and Relationships (1.3.1) | Copyright, contact info |
| Heading hierarchy | <h1> to <h6> in order | Info and Relationships (1.3.1) | Don't skip levels (h2 → h4) |
Example: WCAG 2.1 AA compliant semantic HTML structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Web Page</title>
</head>
<body>
<!-- Skip to main content link -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Page header with site branding -->
<header role="banner">
<h1>Site Name</h1>
<p>Tagline describing the site</p>
</header>
<!-- Main navigation -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
<!-- Main content area -->
<main id="main-content">
<h2>Page Title</h2>
<!-- Article section -->
<article>
<h3>Article Heading</h3>
<p>Article content with <a href="#">meaningful link text</a>.</p>
<section>
<h4>Subsection Heading</h4>
<p>Subsection content.</p>
</section>
</article>
<!-- Accessible form -->
<form action="/submit" method="post">
<fieldset>
<legend>Contact Information</legend>
<label for="name">Name</label>
<input type="text" id="name" name="name" required aria-required="true">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-hint"
>
<span id="email-hint" class="hint">We'll never share your email.</span>
</fieldset>
<button type="submit">Submit</button>
</form>
<!-- Accessible table -->
<table>
<caption>Monthly Sales Data</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$10,000</td>
</tr>
</tbody>
</table>
<!-- Accessible image -->
<figure>
<img
src="chart.png"
alt="Bar chart showing 25% increase in sales over Q1"
>
<figcaption>Sales growth in Q1 2025</figcaption>
</figure>
<!-- Decorative image (empty alt) -->
<img src="decorative-line.png" alt="" role="presentation">
</main>
<!-- Sidebar content -->
<aside aria-label="Related content">
<h2>Related Articles</h2>
<ul>
<li><a href="/article1">Article 1</a></li>
<li><a href="/article2">Article 2</a></li>
</ul>
</aside>
<!-- Page footer -->
<footer role="contentinfo">
<p>© 2025 Company Name</p>
<nav aria-label="Footer navigation">
<a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Service</a>
</nav>
</footer>
</body>
</html>
<!-- CSS for skip link -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
2. ARIA Labels Roles Screen Readers
| ARIA Attribute | Purpose | Example | Use Case |
|---|---|---|---|
| role | Defines element's purpose | role="button" |
Non-semantic elements as interactive |
| aria-label | Provides accessible name | aria-label="Close dialog" |
Icon buttons without text |
| aria-labelledby | References element for label | aria-labelledby="heading-id" |
Associate region with heading |
| aria-describedby | Additional description | aria-describedby="help-text" |
Form field hints, tooltips |
| aria-live | Announces dynamic content | aria-live="polite" |
Status messages, notifications |
| aria-hidden | Hides from screen readers | aria-hidden="true" |
Decorative icons, duplicates |
| aria-expanded | Expandable state | aria-expanded="false" |
Accordions, dropdowns |
| aria-current | Current item in set | aria-current="page" |
Active navigation link |
Example: ARIA attributes for accessible components
// Icon button with aria-label
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
// Search form with aria-labelledby
<form role="search" aria-labelledby="search-heading">
<h2 id="search-heading">Search Products</h2>
<input type="search" aria-label="Search query">
<button type="submit">Search</button>
</form>
// Form field with aria-describedby
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="password-requirements"
aria-invalid="false"
>
<div id="password-requirements">
Must be at least 8 characters
</div>
// Accordion with aria-expanded
function Accordion() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
aria-expanded={isOpen}
aria-controls="accordion-content"
onClick={() => setIsOpen(!isOpen)}
>
Show Details
</button>
{isOpen && (
<div id="accordion-content" role="region">
Accordion content
</div>
)}
</div>
);
}
// Live region for notifications
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{statusMessage}
</div>
// Alert for errors
<div role="alert" aria-live="assertive">
{errorMessage}
</div>
// Modal dialog
function Modal({ isOpen, onClose, title, children }) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
hidden={!isOpen}
>
<h2 id="dialog-title">{title}</h2>
<div>{children}</div>
<button onClick={onClose} aria-label="Close dialog">
<span aria-hidden="true">×</span>
</button>
</div>
);
}
// Navigation with aria-current
<nav aria-label="Main navigation">
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
// Tabs with ARIA
function Tabs() {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div role="tablist" aria-label="Content tabs">
<button
role="tab"
aria-selected={activeTab === 0}
aria-controls="panel-0"
id="tab-0"
onClick={() => setActiveTab(0)}
>
Tab 1
</button>
<button
role="tab"
aria-selected={activeTab === 1}
aria-controls="panel-1"
id="tab-1"
onClick={() => setActiveTab(1)}
>
Tab 2
</button>
</div>
<div
role="tabpanel"
id="panel-0"
aria-labelledby="tab-0"
hidden={activeTab !== 0}
>
Panel 1 content
</div>
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
hidden={activeTab !== 1}
>
Panel 2 content
</div>
</div>
);
}
// Loading state with aria-busy
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? 'Loading...' : content}
</div>
// Visually hidden but screen-reader accessible
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
<span class="sr-only">Additional context for screen readers</span>
3. Focus Management roving tabindex
| Technique | Implementation | Description | Use Case |
|---|---|---|---|
| tabindex="0" | Includes in tab order | Makes non-interactive elements focusable | Custom widgets |
| tabindex="-1" | Programmatically focusable | Not in tab order, focus via JS | Skip links, roving tabindex |
| Roving tabindex | One item tabindex="0" | Single tab stop, arrow keys navigate | Toolbars, menus, grids |
| Focus trap | Constrain focus within | Prevent focus leaving modal/dialog | Modals, drawers |
| Focus visible | :focus-visible |
Show focus only for keyboard users | Better UX than :focus |
| Focus restoration | Return focus after action | Focus trigger after closing modal | Modal dialogs |
Example: Focus management and roving tabindex
// Roving tabindex for toolbar
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
const handleKeyDown = (e, index) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % items.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = items.length - 1;
break;
default:
return;
}
e.preventDefault();
setFocusedIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting">
{items.map((item, index) => (
<button
key={item.id}
ref={el => itemRefs.current[index] = el}
tabIndex={index === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
aria-label={item.label}
>
{item.icon}
</button>
))}
</div>
);
}
// Focus trap for modal
import { useEffect, useRef } from 'react';
function FocusTrap({ children }) {
const trapRef = useRef(null);
useEffect(() => {
const trap = trapRef.current;
if (!trap) return;
const focusableElements = trap.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
trap.addEventListener('keydown', handleTabKey);
firstElement?.focus();
return () => trap.removeEventListener('keydown', handleTabKey);
}, []);
return <div ref={trapRef}>{children}</div>;
}
// Modal with focus management
function Modal({ isOpen, onClose, children }) {
const previousFocus = useRef(null);
const modalRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocus.current = document.activeElement;
// Focus modal
modalRef.current?.focus();
} else {
// Restore focus when closed
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<FocusTrap>
{children}
<button onClick={onClose}>Close</button>
</FocusTrap>
</div>
);
}
// Focus-visible CSS
button:focus {
outline: none; /* Remove default */
}
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
// Custom hook for roving tabindex
function useRovingTabIndex(size) {
const [currentIndex, setCurrentIndex] = useState(0);
const handleKeyDown = useCallback((e) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
setCurrentIndex((prev) => (prev + 1) % size);
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
setCurrentIndex((prev) => (prev - 1 + size) % size);
break;
case 'Home':
e.preventDefault();
setCurrentIndex(0);
break;
case 'End':
e.preventDefault();
setCurrentIndex(size - 1);
break;
}
}, [size]);
return { currentIndex, setCurrentIndex, handleKeyDown };
}
// Usage
function Menu() {
const items = ['Item 1', 'Item 2', 'Item 3'];
const { currentIndex, handleKeyDown } = useRovingTabIndex(items.length);
return (
<div role="menu">
{items.map((item, index) => (
<div
key={index}
role="menuitem"
tabIndex={index === currentIndex ? 0 : -1}
onKeyDown={handleKeyDown}
>
{item}
</div>
))}
</div>
);
}
// Skip to main content
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<main id="main-content" tabIndex={-1}>
{/* Main content */}
</main>
4. Color Contrast Checker WebAIM
| Level | Contrast Ratio | Requirement | Use Case |
|---|---|---|---|
| AA Normal Text | 4.5:1 | Minimum for body text | <18px or <14px bold |
| AA Large Text | 3:1 | Large or bold text | >=18px or >=14px bold |
| AAA Normal Text | 7:1 | Enhanced contrast | Higher accessibility |
| AAA Large Text | 4.5:1 | Enhanced for large text | Better readability |
| UI Components | 3:1 | Form controls, icons | Interactive elements |
| Focus Indicators | 3:1 | Focus state contrast | Keyboard navigation |
Example: Color contrast compliance and testing
/* WCAG AA Compliant Colors */
/* Good contrast examples (AA compliant) */
:root {
--text-dark: #1a1a1a; /* On white: 16.1:1 (AAA) */
--text-medium: #595959; /* On white: 7:1 (AAA) */
--text-light: #757575; /* On white: 4.6:1 (AA) */
--primary: #0066cc; /* On white: 4.7:1 (AA) */
--primary-dark: #004080; /* On white: 8.6:1 (AAA) */
--success: #008000; /* On white: 4.5:1 (AA) */
--error: #c00000; /* On white: 7.3:1 (AAA) */
--warning: #c68400; /* On white: 4.5:1 (AA) */
/* Dark mode */
--bg-dark: #1a1a1a;
--text-dark-mode: #e6e6e6; /* On dark: 11.6:1 (AAA) */
}
/* Bad contrast examples (WCAG fails) */
.bad-contrast {
color: #999; /* On white: 2.8:1 (FAIL) */
background: #fff;
}
/* Good contrast example */
.good-contrast {
color: #595959; /* On white: 7:1 (AAA) */
background: #fff;
}
/* Button with sufficient contrast */
.button {
background: #0066cc; /* Background color */
color: #ffffff; /* Text: 4.7:1 (AA) */
border: 2px solid #004080; /* Border: 8.6:1 (AAA) */
}
.button:focus {
outline: 2px solid #0066cc; /* 3:1 with background (AA) */
outline-offset: 2px;
}
/* Link colors with sufficient contrast */
a {
color: #0066cc; /* 4.7:1 on white (AA) */
text-decoration: underline; /* Don't rely on color alone */
}
a:visited {
color: #551a8b; /* 6.4:1 on white (AA) */
}
/* Testing contrast in JavaScript */
function getContrast(foreground, background) {
const getLuminance = (color) => {
const rgb = color.match(/\d+/g).map(Number);
const [r, g, b] = rgb.map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const l1 = getLuminance(foreground);
const l2 = getLuminance(background);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Check if contrast meets WCAG AA
const contrastRatio = getContrast('rgb(0, 102, 204)', 'rgb(255, 255, 255)');
const meetsAA = contrastRatio >= 4.5; // true
// React component for contrast checking
function ContrastChecker({ foreground, background }) {
const ratio = getContrast(foreground, background);
const meetsAA = ratio >= 4.5;
const meetsAAA = ratio >= 7;
return (
<div>
<div style={{ color: foreground, background }}>
Sample Text
</div>
<p>Contrast Ratio: {ratio.toFixed(2)}:1</p>
<p>WCAG AA: {meetsAA ? '✓ Pass' : '✗ Fail'}</p>
<p>WCAG AAA: {meetsAAA ? '✓ Pass' : '✗ Fail'}</p>
</div>
);
}
/* Dark mode with proper contrast */
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--text: #e6e6e6; /* 11.6:1 (AAA) */
--text-muted: #b3b3b3; /* 5.7:1 (AA) */
--primary: #4d94ff; /* 5.1:1 on dark (AA) */
}
body {
background: var(--bg);
color: var(--text);
}
}
/* Don't rely on color alone - use icons/patterns */
.status-success {
color: #008000;
/* Add icon for non-color indicator */
}
.status-success::before {
content: '✓';
margin-right: 0.5rem;
}
.status-error {
color: #c00000;
}
.status-error::before {
content: '✗';
margin-right: 0.5rem;
}
/* WebAIM Contrast Checker Tools */
// Online: https://webaim.org/resources/contrastchecker/
// Browser DevTools: Chrome/Edge Lighthouse, Firefox Accessibility Inspector
// VS Code Extensions: webhint, axe Accessibility Linter
// npm packages: axe-core, pa11y
5. Keyboard Navigation Testing
| Key | Action | Expected Behavior | Component |
|---|---|---|---|
| Tab | Move forward | Focus next interactive element | All focusable elements |
| Shift + Tab | Move backward | Focus previous interactive element | All focusable elements |
| Enter | Activate | Trigger button/link action | Buttons, links |
| Space | Activate | Toggle checkbox, press button | Buttons, checkboxes |
| Arrow keys | Navigate within | Move between items in group | Radio, select, tabs, menus |
| Escape | Close/Cancel | Close modal, dismiss menu | Modals, menus, tooltips |
| Home/End | Jump to start/end | First/last item in list | Lists, select, inputs |
Example: Keyboard navigation implementation
// Keyboard-accessible dropdown
function Dropdown({ items, label }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const buttonRef = useRef(null);
const itemRefs = useRef([]);
const handleKeyDown = (e) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(!isOpen);
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
const next = (selectedIndex + 1) % items.length;
setSelectedIndex(next);
itemRefs.current[next]?.focus();
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
const prev = (selectedIndex - 1 + items.length) % items.length;
setSelectedIndex(prev);
itemRefs.current[prev]?.focus();
}
break;
case 'Home':
e.preventDefault();
setSelectedIndex(0);
itemRefs.current[0]?.focus();
break;
case 'End':
e.preventDefault();
setSelectedIndex(items.length - 1);
itemRefs.current[items.length - 1]?.focus();
break;
}
};
return (
<div>
<button
ref={buttonRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(!isOpen)}
>
{label}
</button>
{isOpen && (
<ul role="listbox">
{items.map((item, index) => (
<li
key={item.id}
ref={el => itemRefs.current[index] = el}
role="option"
tabIndex={-1}
aria-selected={index === selectedIndex}
onKeyDown={handleKeyDown}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// Keyboard navigation for tabs
function Tabs({ tabs }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef([]);
const handleTabKeyDown = (e, index) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={el => tabRefs.current[index] = el}
role="tab"
tabIndex={index === activeIndex ? 0 : -1}
aria-selected={index === activeIndex}
aria-controls={`panel-${index}`}
onKeyDown={(e) => handleTabKeyDown(e, index)}
onClick={() => setActiveIndex(index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${index}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
// Modal with keyboard handling
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>Close (Esc)</button>
</div>
);
}
// Custom checkbox with keyboard support
function Checkbox({ checked, onChange, label }) {
const handleKeyDown = (e) => {
if (e.key === ' ') {
e.preventDefault();
onChange(!checked);
}
};
return (
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => onChange(!checked)}
>
{label}
</div>
);
}
// Keyboard testing checklist
const keyboardTests = [
'Can navigate all interactive elements with Tab',
'Can operate all functionality with keyboard only',
'Focus indicator is clearly visible',
'Tab order follows logical sequence',
'No keyboard traps (can escape all contexts)',
'Arrow keys work in composite widgets',
'Enter/Space activate buttons',
'Escape closes modals and menus',
'Can skip repetitive content (skip links)',
'Custom widgets follow ARIA keyboard patterns',
];
// Testing script
function testKeyboardNavigation() {
console.log('Keyboard Navigation Test');
// Get all focusable elements
const focusable = document.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
console.log(`Found ${focusable.length} focusable elements`);
// Check tab order
focusable.forEach((el, i) => {
console.log(`${i + 1}. ${el.tagName} - ${el.textContent?.trim()}`);
});
}
6. axe-core Automated Testing
| Tool | Usage | Description | Integration |
|---|---|---|---|
| axe-core | JavaScript library | Automated accessibility testing engine | Runtime, CI/CD |
| @axe-core/react | React integration | Logs violations in development | Development mode |
| jest-axe | Jest matcher | Accessibility assertions in tests | Unit/integration tests |
| cypress-axe | Cypress plugin | E2E accessibility testing | End-to-end tests |
| axe DevTools | Browser extension | Manual testing in DevTools | Development, QA |
| pa11y | CLI tool | Automated accessibility testing | CI/CD pipelines |
Example: Automated accessibility testing with axe-core
// Install packages
npm install --save-dev axe-core @axe-core/react jest-axe cypress-axe
// 1. axe-core in React (development mode)
// index.tsx or main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then(axe => {
axe.default(React, ReactDOM, 1000);
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
// 2. jest-axe for unit tests
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test specific component
test('button should be accessible', async () => {
const { container } = render(
<button>Click me</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test with custom rules
test('form should be accessible', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true },
'label': { enabled: true },
}
});
expect(results).toHaveNoViolations();
});
// 3. cypress-axe for E2E tests
// cypress/support/e2e.ts
import 'cypress-axe';
// cypress/e2e/accessibility.cy.ts
describe('Accessibility tests', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('should not have accessibility violations on home page', () => {
cy.checkA11y();
});
it('should not have violations after interaction', () => {
cy.get('button').click();
cy.checkA11y();
});
it('should check specific element', () => {
cy.checkA11y('.navigation');
});
it('should check with options', () => {
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'],
},
});
});
it('should exclude specific elements', () => {
cy.checkA11y({
exclude: ['.advertisement'],
});
});
});
// 4. Playwright with axe-core
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('should check specific section', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('.main-content')
.exclude('.ads')
.analyze();
expect(results.violations).toEqual([]);
});
// 5. pa11y for CI/CD
// package.json
{
"scripts": {
"test:a11y": "pa11y-ci"
}
}
// .pa11yci.json
{
"defaults": {
"timeout": 5000,
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"]
},
"urls": [
"http://localhost:3000",
"http://localhost:3000/about",
"http://localhost:3000/contact"
]
}
// 6. Custom axe-core integration
import { run } from 'axe-core';
async function checkAccessibility(element) {
try {
const results = await run(element || document.body, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
});
if (results.violations.length > 0) {
console.error('Accessibility violations:', results.violations);
results.violations.forEach(violation => {
console.group(violation.help);
console.log('Impact:', violation.impact);
console.log('Description:', violation.description);
console.log('Nodes:', violation.nodes);
console.groupEnd();
});
} else {
console.log('✓ No accessibility violations found');
}
return results;
} catch (error) {
console.error('Accessibility check failed:', error);
}
}
// Usage in development
if (process.env.NODE_ENV === 'development') {
window.checkA11y = checkAccessibility;
// In console: checkA11y()
}
// 7. GitHub Actions CI
// .github/workflows/accessibility.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build
- run: npm run test:a11y
// 8. React Testing Library with axe
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
test('modal should be accessible', async () => {
const { container } = render(<App />);
// Open modal
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
// Check accessibility
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Accessibility Best Practices Summary
- Semantic HTML - Use proper HTML5 elements (header, nav, main, article, aside, footer) with heading hierarchy
- ARIA Attributes - Use aria-label, aria-labelledby, aria-describedby for context; aria-live for dynamic content; aria-expanded for state
- Focus Management - Implement roving tabindex for toolbars/menus, focus traps for modals, restore focus after interactions
- Color Contrast - WCAG AA requires 4.5:1 for normal text, 3:1 for large text and UI components; use :focus-visible for keyboard users
- Keyboard Navigation - Tab/Shift+Tab for focus, Enter/Space to activate, Arrow keys for navigation, Escape to close, Home/End to jump
- Automated Testing - Use axe-core in development (@axe-core/react), jest-axe for unit tests, cypress-axe for E2E, pa11y for CI/CD
- Manual Testing - Test with keyboard only, screen readers (NVDA, JAWS, VoiceOver), axe DevTools browser extension