| created | "2025-12-16T00:00:00.000Z" |
| modified | "2026-05-09T00:00:00.000Z" |
| reviewed | "2026-04-25T00:00:00.000Z" |
| name | accessibility-implementation |
| description | WCAG 2.1/2.2 compliance, ARIA patterns, keyboard nav, focus management, a11y testing. Use when implementing accessible components or user mentions WCAG/ARIA/screen readers. |
| user-invocable | false |
| allowed-tools | Glob, Grep, Read, Edit, Write, Bash(npm *), Bash(npx *), Bash(axe *), BashOutput, TodoWrite, WebSearch, WebFetch |
Accessibility Implementation
Technical implementation of WCAG guidelines, ARIA patterns, and assistive technology support.
When to Use This Skill
| Use this skill when... | Use design-tokens instead when... |
|---|
| Implementing WCAG 2.1/2.2 success criteria in code | Setting up CSS custom properties or theme systems |
| Adding ARIA roles, states, or live regions | Defining semantic colour tokens used by themes |
| Wiring keyboard navigation, focus traps, or skip links | Organizing primitive/semantic/component token tiers |
| Auditing components with axe-core, jest-axe, or Playwright | Implementing light/dark mode token overrides |
Core Expertise
- WCAG Compliance: Implementing WCAG 2.1/2.2 success criteria in code
- ARIA Patterns: Correct usage of roles, states, and properties
- Keyboard Navigation: Focus management, key handlers, logical tab order
- Screen Readers: Content structure, announcements, live regions
- Testing: Automated and manual accessibility testing
WCAG Quick Reference
Level A (Must Have)
| Criterion | Implementation |
|---|
| 1.1.1 Non-text Content | alt for images, labels for inputs |
| 1.3.1 Info and Relationships | Semantic HTML, ARIA relationships |
| 2.1.1 Keyboard | All interactive elements keyboard accessible |
| 2.4.1 Bypass Blocks | Skip links, landmarks |
| 4.1.2 Name, Role, Value | ARIA labels, roles for custom widgets |
Level AA (Should Have)
| Criterion | Implementation |
|---|
| 1.4.3 Contrast (Minimum) | 4.5:1 text, 3:1 large text |
| 1.4.11 Non-text Contrast | 3:1 for UI components |
| 2.4.6 Headings and Labels | Descriptive, hierarchical headings |
| 2.4.7 Focus Visible | Visible focus indicator (2px+ outline) |
ARIA Patterns
Buttons and Links
<div role="button" tabindex="0"
aria-pressed="false"
onkeydown="handleKeyDown(event)">
Toggle Feature
</div>
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<a href="/page">Go to page</a>
<button type="button">Submit form</button>
Form Controls
<label for="email">Email address</label>
<input id="email" type="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
required>
<div id="email-hint">We'll never share your email</div>
<div id="email-error" role="alert">Please enter a valid email</div>
<fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="notif" value="email"> Email</label>
<label><input type="checkbox" name="notif" value="sms"> SMS
Country
United States
United Kingdom
Modal Dialog
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-desc">Are you sure you want to proceed?</p>
<button>Cancel</button>
<button>Confirm</button>
</div>
function trapFocus(dialog: HTMLElement) {
const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
closeDialog();
}
});
first.focus();
}
Tabs
<div role="tablist" aria-label="Settings tabs">
<button role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1">
General
</button>
<button role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1">
Privacy
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
General settings content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Privacy settings content
</div>
tablist.addEventListener('keydown', (e) => {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const current = tabs.indexOf(document.activeElement as Element);
let next: number;
switch (e.key) {
case 'ArrowRight':
next = (current + 1) % tabs.length;
break;
case 'ArrowLeft':
next = (current - 1 + tabs.length) % tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
(tabs[next] as HTMLElement).focus();
activateTab(tabs[next]);
});
Live Regions
<div role="status" aria-live="polite">
Form saved successfully
</div>
<div role="alert" aria-live="assertive">
Error: Connection lost
</div>
<div aria-live="polite" aria-atomic="true">
Loading: 45% complete
</div>
Keyboard Navigation
Standard Key Bindings
| Key | Behavior |
|---|
| Tab | Move to next focusable element |
| Shift+Tab | Move to previous focusable element |
| Enter/Space | Activate button, select option |
| Escape | Close modal, cancel operation |
| Arrow keys | Navigate within component (tabs, menu, listbox) |
| Home/End | Go to first/last item in list |
Focus Management
const triggerElement = document.activeElement;
openModal();
closeModal();
triggerElement?.focus();
function showValidationErrors() {
const firstError = document.querySelector('[aria-invalid="true"]');
(firstError as HTMLElement)?.focus();
}
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">...</main>
Roving Tabindex
function setRovingTabindex(container: HTMLElement, selector: string) {
const items = container.querySelectorAll(selector);
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
container.addEventListener('keydown', (e) => {
const current = Array.from(items).indexOf(document.activeElement as Element);
let next = current;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (current + 1) % items.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (current - 1 + items.length) % items.length;
}
if (next !== current) {
items[current].setAttribute('tabindex', );
items[next].(, );
(items[next] ).();
e.();
}
});
}
Testing
Automated Testing
npx @axe-core/cli https://localhost:3000
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json
npx pa11y http://localhost:3000
npm install --save-dev jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('component is accessible', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
Manual Testing Checklist
Keyboard Navigation
Screen Reader Testing
Visual Testing
Common Fixes
Missing Accessible Name
<button><svg>...</svg></button>
<button aria-label="Close">
<svg aria-hidden="true">...</svg>
</button>
Missing Form Labels
<input placeholder="Email">
<label for="email">Email</label>
<input id="email" type="email">
<label for="search" class="visually-hidden">Search</label>
<input id="search" type="search" placeholder="Search...">
Missing Heading Structure
<h1>Page Title</h1>
<h3>Section</h3>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
Focus Not Visible
button:focus { outline: none; }
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Color Contrast
.text { color: #999; background: #fff; }
.text { color: #595959; background: #fff; }
CSS Utilities
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Best Practices
Semantic HTML First
Use native HTML elements before ARIA. A <button> is better than <div role="button">.
Don't Override Default Behavior
Native elements have built-in accessibility. Don't break it with JavaScript.
Test with Real Users
Automated tools catch ~30% of issues. Manual testing with assistive technology is essential.
Provide Multiple Ways
Offer keyboard, mouse, and touch alternatives for all interactions.
References