--- name: accessibility-a11y description: WCAG 2.2 compliance, ARIA patterns, keyboard navigation, screen readers, automated testing --- # Accessibility (a11y) ## Overview This skill covers building accessible web applications that work for everyone, including people using screen readers, keyboard-only navigation, switch devices, and other assistive technologies. It addresses WCAG 2.2 compliance at AA and AAA levels, correct ARIA usage, focus management, color contrast, reduced motion support, and automated testing integration. Use this skill when building new UI components, reviewing existing interfaces for accessibility compliance, fixing a11y audit findings, or integrating automated accessibility testing into CI/CD pipelines. --- ## Core Principles 1. **Semantic HTML first** - Native HTML elements (`
{children}
); } ``` ```css /* Focus trap is handled natively by showModal() */ dialog::backdrop { background: rgba(0, 0, 0, 0.5); } dialog .dialog-close:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } ``` **Why:** The native `` element with `showModal()` provides focus trapping, Escape key handling, and proper `role="dialog"` semantics automatically. Custom modal implementations almost always have focus trap bugs. Using the native element gives you correct behavior for free. --- ### Pattern 2: Accessible Form with Error Handling **When to use:** Any form that collects user input and validates it. **Implementation:** ```tsx interface FormFieldProps { id: string; label: string; type?: string; required?: boolean; error?: string; description?: string; value: string; onChange: (value: string) => void; } function FormField({ id, label, type = "text", required = false, error, description, value, onChange, }: FormFieldProps) { const descriptionId = description ? `${id}-description` : undefined; const errorId = error ? `${id}-error` : undefined; // Build aria-describedby from available descriptions const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined; return (
{description && (

{description}

)} onChange(e.target.value)} required={required} aria-invalid={error ? "true" : undefined} aria-describedby={describedBy} aria-required={required} /> {error && ( )}
); } // Form-level error summary for screen readers function ErrorSummary({ errors }: { errors: Record }) { const errorEntries = Object.entries(errors); if (errorEntries.length === 0) return null; return (

{errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found

    {errorEntries.map(([field, message]) => (
  • {message}
  • ))}
); } ``` ```css /* Screen-reader only class */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } .field-error { color: var(--color-error); font-size: 0.875rem; margin-top: 0.25rem; } /* Never rely on color alone for errors - include icon */ .field-error::before { content: ""; /* Error icon via background-image */ } input[aria-invalid="true"] { border-color: var(--color-error); /* Also use a thicker border or icon, not just color */ border-width: 2px; } ``` **Why:** Forms are the most common source of accessibility failures. This pattern ensures every field has a programmatic label, errors are announced via `role="alert"`, error messages are linked to inputs via `aria-describedby`, and the error summary lets keyboard users jump directly to problematic fields. --- ### Pattern 3: Keyboard Navigation for Custom Widgets **When to use:** Building custom interactive components (tabs, menus, listboxes, comboboxes) that don't map to native HTML elements. **Implementation:** ```tsx // Accessible tabs following WAI-ARIA Authoring Practices interface Tab { id: string; label: string; content: React.ReactNode; } function Tabs({ tabs }: { tabs: Tab[] }) { const [activeIndex, setActiveIndex] = useState(0); const handleKeyDown = (e: React.KeyboardEvent, index: number) => { 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; // Don't prevent default for other keys } e.preventDefault(); setActiveIndex(newIndex); // Move focus to the newly active tab const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`); tabElement?.focus(); }; return (
{tabs.map((tab, index) => ( ))}
{tabs.map((tab, index) => ( ))}
); } ``` **Why:** Custom widgets must implement the expected keyboard interaction pattern from WAI-ARIA Authoring Practices. Tabs use Arrow keys to move between tabs (not Tab key), with `tabIndex={-1}` on inactive tabs so only the active tab is in the tab order. This matches the mental model of screen reader users. --- ### Pattern 4: Live Regions for Dynamic Content **When to use:** When content updates without a page reload and screen reader users need to be informed (notifications, search results, loading states). **Implementation:** ```tsx // Toast notification system with live regions function ToastContainer({ toasts }: { toasts: Toast[] }) { return (
{toasts.map((toast) => (
{toast.message}
))}
); } // For urgent errors, use role="alert" (assertive) function CriticalError({ message }: { message: string }) { return (
{message}
); } // Search results count announcement function SearchResults({ query, count }: { query: string; count: number }) { return ( <>
{count} results found for "{query}"
{/* Visual results list */} ); } ``` **Why:** Screen readers don't monitor the DOM for visual changes. Live regions explicitly tell assistive technology to announce new content. Use `aria-live="polite"` for non-urgent updates (search results, toasts) and `role="alert"` for urgent messages (errors, session expiry). --- ### Pattern 5: Automated Accessibility Testing **When to use:** Every project, integrated into CI/CD and development workflow. **Implementation:** ```typescript // Jest + axe-core for component testing import { render } from "@testing-library/react"; import { axe, toHaveNoViolations } from "jest-axe"; expect.extend(toHaveNoViolations); describe("LoginForm", () => { it("should have no accessibility violations", async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); }); it("should have no violations in error state", async () => { const { container } = render(); // Trigger validation errors fireEvent.click(screen.getByRole("button", { name: /submit/i })); const results = await axe(container); expect(results).toHaveNoViolations(); }); }); ``` ```typescript // Playwright + axe for E2E accessibility testing import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; test.describe("accessibility", () => { test("home page passes axe audit", async ({ page }) => { await page.goto("/"); const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag22aa"]) .analyze(); expect(results.violations).toEqual([]); }); test("modal dialog passes axe audit when open", async ({ page }) => { await page.goto("/dashboard"); await page.getByRole("button", { name: "Create project" }).click(); const results = await new AxeBuilder({ page }) .include(".dialog") .analyze(); expect(results.violations).toEqual([]); }); }); ``` ```yaml # GitHub Actions CI integration name: Accessibility Audit on: [pull_request] jobs: a11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run build - name: Run axe accessibility tests run: npx playwright test --grep "accessibility" - name: Run pa11y on built pages run: | npx serve -s build -l 3000 & sleep 3 npx pa11y-ci --config .pa11yci.json ``` **Why:** Automated testing catches structural issues (missing labels, invalid ARIA, contrast failures) consistently and early. Catching 30% of issues automatically in CI is better than catching 0% and discovering problems after launch. Combine with manual testing for full coverage. --- ## Color Contrast Quick Reference | Text Size | WCAG AA | WCAG AAA | |---|---|---| | Normal text (< 18px / 14px bold) | 4.5:1 | 7:1 | | Large text (>= 18px / 14px bold) | 3:1 | 4.5:1 | | UI components & graphical objects | 3:1 | 3:1 | | Decorative / disabled elements | No requirement | No requirement | --- ## ARIA Quick Reference | Need | Use This | Not This | |---|---|---| | Label for input | `