Accessibility Automation Expert
Implement and enforce WCAG 2.2 AA/AAA compliance through automated testing, keyboard navigation, ARIA patterns, screen reader optimization, and focus management.
Activation Triggers
Activate on: accessibility audit failures, WCAG compliance requirements, keyboard navigation broken, screen reader not announcing content, axe-core violations, focus trap for modals/dialogs, aria-* attribute questions, skip navigation links.
NOT for: design-level accessibility review (color choices, layout decisions) -- use design-accessibility-auditor. Pure color contrast checking -- use css-in-js-architect with OKLCH.
Quick Start
- Run axe-core --
npx @axe-core/cli http://localhost:3000or integrate@axe-core/reactin dev mode for console warnings. - Fix critical violations first -- missing alt text, missing form labels, insufficient contrast, missing landmarks.
- Implement keyboard navigation -- every interactive element must be reachable and operable with Tab, Enter, Space, Escape, and Arrow keys.
- Add focus management -- trap focus in modals, restore focus on close, manage focus on route change.
- Test with a screen reader -- VoiceOver (macOS), NVDA (Windows), or TalkBack (Android) for real validation.
Core Capabilities
| Domain | Technologies | Key Patterns |
|---|---|---|
| Automated Testing | axe-core, Lighthouse, jest-axe, Playwright axe | CI/CD accessibility gates |
| Keyboard Navigation | tabindex, onKeyDown, roving tabindex | Arrow key navigation, focus groups |
| Screen Readers | ARIA roles, live regions, aria-label | Announcements, state changes, descriptions |
| Focus Management | focus-visible, focus trap, inert attribute | Modal focus lock, skip links, route change focus |
| Semantic HTML | <main>, <nav>, <article>, <aside> | Landmarks, heading hierarchy, lists |
| Forms | <label>, aria-describedby, aria-invalid | Error announcement, required fields, fieldsets |
Architecture Patterns
Pattern 1: Automated axe-core in CI
// e2e/accessibility.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/products', '/checkout', '/account'];
for (const path of pages) {
test(`${path} has no accessibility violations`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // WCAG 2.2 AA
.analyze();
expect(results.violations).toEqual([]);
});
}
// Dev mode: axe-core in React (shows violations in console)
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
Pattern 2: Focus Management for Modals
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save current focus to restore later
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// Trap focus within container
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// Use inert on background content
const mainContent = document.querySelector('main');
mainContent?.setAttribute('inert', '');
return () => {
container.removeEventListener('keydown', handleKeyDown);
mainContent?.removeAttribute('inert');
previousFocusRef.current?.focus(); // restore focus
};
}, [isOpen]);
return containerRef;
}
Pattern 3: ARIA Live Regions for Dynamic Content
// Announce form errors, loading states, and updates to screen readers
function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.getElementById(`aria-live-${priority}`);
if (el) {
el.textContent = ''; // Clear first to trigger re-announcement
requestAnimationFrame(() => { el.textContent = message; });
}
}, []);
return announce;
}
// Mount once in layout:
function AriaLiveRegions() {
return (
<>
<div id="aria-live-polite" aria-live="polite" aria-atomic="true" className="sr-only" />
<div id="aria-live-assertive" aria-live="assertive" aria-atomic="true" className="sr-only" />
</>
);
}
┌─ Accessibility Testing Pyramid ────────────────────┐
│ │
│ ▲ Manual Screen Reader Testing │
│ ╱ ╲ (VoiceOver, NVDA — quarterly) │
│ ╱───╲ │
│ ╱ ╲ Playwright + axe-core E2E │
│ ╱ E2E ╲ (every page, CI gate) │
│ ╱─────────╲ │
│ ╱ ╲ jest-axe Component Tests │
│ ╱ Component ╲ (per interactive component) │
│ ╱───────────────╲ │
│ ╱ ╲ ESLint jsx-a11y │
│╱ Static Lint ╲ (on every commit) │
│╲___________________╱ │
└─────────────────────────────────────────────────────┘
Anti-Patterns
divwithonClickinstead ofbutton-- divs have no keyboard interaction, no role, and no focus. Use semantic<button>or<a>elements. If you must use a div, addrole="button",tabindex="0", andonKeyDownfor Enter/Space.aria-labelon everything -- over-labeling creates noise for screen reader users. Prefer visible text labels; usearia-labelonly when visible text is impossible.- Hiding focus outlines --
outline: nonewith no replacement makes keyboard navigation invisible. Use:focus-visiblefor keyboard-only focus indicators that do not appear on mouse click. - Missing skip navigation link -- keyboard users must tab through the entire nav on every page. Add
<a href="#main" class="sr-only focus:not-sr-only">Skip to main content</a>as the first focusable element. - Toast notifications without live regions -- screen readers cannot detect dynamically added toasts. Use
aria-live="polite"regions to announce them.
Quality Checklist
- axe-core CI gate passes with zero violations on all pages
-
eslint-plugin-jsx-a11yenabled with no warnings - All images have descriptive
alttext (oralt=""for decorative images) - All form inputs have associated
<label>elements (not just placeholder text) - Heading hierarchy is sequential (
h1>h2>h3, no skipping) - Page landmarks present:
<main>,<nav>,<header>,<footer> - Skip navigation link is the first focusable element
- Focus visible on all interactive elements (
:focus-visiblestyles defined) - Modals trap focus and restore focus on close
- Dynamic content changes announced via
aria-liveregions - Color contrast ratios meet WCAG AA (4.5:1 normal text, 3:1 large text)
- All functionality operable with keyboard only (Tab, Enter, Space, Escape, Arrow keys)