| name | accessibility-auditor |
| description | Audits and implements web accessibility (a11y) following WCAG 2.1 guidelines with ARIA patterns, keyboard navigation, screen reader support, and contrast checking. Use when users request "accessibility audit", "a11y review", "WCAG compliance", "screen reader support", or "keyboard navigation". |
Accessibility Auditor
Build inclusive web experiences with WCAG 2.1 compliance and comprehensive a11y patterns.
Core Workflow
- Audit existing code: Identify accessibility issues
- Check WCAG compliance: Verify against success criteria
- Fix semantic HTML: Use proper elements and landmarks
- Add ARIA attributes: Enhance assistive technology support
- Implement keyboard nav: Ensure full keyboard accessibility
- Test with tools: Automated and manual testing
- Verify with screen readers: Real-world testing
WCAG 2.1 Quick Reference
Compliance Levels
| Level | Description | Requirement |
|---|
| A | Minimum accessibility | Must have |
| AA | Standard compliance | Industry standard |
| AAA | Enhanced accessibility | Nice to have |
Four Principles (POUR)
- Perceivable: Content must be presentable to all senses
- Operable: Interface must be navigable by all users
- Understandable: Content must be clear and predictable
- Robust: Content must work with assistive technologies
Semantic HTML
Use Proper Elements
<div class="header">
<div class="nav">
<div onclick="navigate()">Home</div>
</div>
</div>
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
Document Landmarks
<body>
<header>
<nav aria-label="Main">...</nav>
</header>
<main id="main-content">
<article>
<h1>Page Title</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section</h2>
</section>
</article>
<aside aria-label="Related content">...</aside>
</main>
<footer>...</footer>
</body>
Heading Hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h3>Subsection</h3>
<h2>Section</h2>
<h3>Subsection</h3>
ARIA Patterns
Buttons
<button type="button" onClick={handleClick}>
Click me
</button>
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Click me
</div>
Modals / Dialogs
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement;
modalRef.current?.focus();
document.body.style.overflow = 'hidden';
} else {
(previousActiveElement.current as HTMLElement)?.focus();
... = ;
}
{
... = ;
};
}, [isOpen]);
( {
= () => {
(e. === && isOpen) {
();
}
};
.(, handleEscape);
.(, handleEscape);
}, [isOpen, onClose]);
(!isOpen) ;
(
);
}
Tabs
import { useState, useRef, KeyboardEvent } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
export function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case 'ArrowLeft':
newIndex = index === 0 ? tabs.length - 1 : index - 1;
break;
case 'ArrowRight':
newIndex = index === tabs.length - 1 ? 0 : index + 1;
break;
case 'Home':
newIndex = 0;
;
:
newIndex = tabs. - ;
;
:
;
}
e.();
(tabs[newIndex].);
tabRefs.[newIndex]?.();
};
(
);
}
Dropdown Menu
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
interface MenuItem {
id: string;
label: string;
onClick: () => void;
}
export function Dropdown({ label, items }: { label: string; items: MenuItem[] }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const menuRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) => (prev + ) % items.);
}
;
:
e.();
( (prev - + items.) % items.);
;
:
:
e.();
(isOpen && activeIndex >= ) {
items[activeIndex].();
();
buttonRef.?.();
} {
();
}
;
:
();
buttonRef.?.();
;
}
};
( {
= () => {
(menuRef. && !menuRef..(e. )) {
();
}
};
.(, handleClickOutside);
.(, handleClickOutside);
}, []);
(
);
}
Focus Management
Skip Links
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:p-4 focus:bg-white focus:z-50">
Skip to main content
</a>
<main id="main-content" tabindex="-1">
...
</main>
Focus Trap for Modals
import { useEffect, useRef } from 'react';
export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
const containerRef = useRef<T>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document. === lastElement) {
e.();
firstElement?.();
}
}
};
container.(, handleTab);
firstElement?.();
container.(, handleTab);
}, [isActive]);
containerRef;
}
Focus Visible Styles
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.focus-visible:focus-visible {
@apply outline-none ring-2 ring-blue-500 ring-offset-2;
}
Color Contrast
WCAG Contrast Requirements
| Level | Normal Text | Large Text |
|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
Large text = 18pt+ (24px) or 14pt+ bold (18.5px)
Accessible Color Pairs
:root {
--text-primary: #1f2937;
--text-secondary: #4b5563;
--text-tertiary: #6b7280;
--link-color: #1d4ed8;
--error-text: #dc2626;
}
Testing Contrast
function getContrastRatio(color1: string, color2: string): number {
const getLuminance = (hex: string): number => {
const rgb = parseInt(hex.slice(1), 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = (rgb >> 0) & 0xff;
const [rs, gs, bs] = [r, g, b].map((c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
const l1 = getLuminance(color1);
const l2 = getLuminance(color2);
const lighter = Math.max(l1, l2);
const darker = .(l1, l2);
(lighter + ) / (darker + );
}
ratio = (, );
passesAA = ratio >= ;
passesAAA = ratio >= ;
Forms
Accessible Form Fields
interface FormFieldProps {
id: string;
label: string;
error?: string;
required?: boolean;
description?: string;
children: React.ReactNode;
}
export function FormField({
id,
label,
error,
required,
description,
children,
}: FormFieldProps) {
const descriptionId = description ? `${id}-description` : undefined;
const errorId = error ? `${id}-error` : undefined;
return (
<div className="space-y-1">
<label htmlFor={id} className="block font-medium">
{label}
{required && (
<span className="text-red-500 ml-1" aria-hidden="true">
*
</span>
)}
{required && <span className="sr-only">(required)</>}
{description && (
{description}
)}
{/* Clone child and add aria attributes */}
{React.cloneElement(children as React.ReactElement, {
id,
'aria-required': required,
'aria-invalid': !!error,
'aria-describedby': [descriptionId, errorId].filter(Boolean).join(' ') || undefined,
})}
{error && (
{error}
)}
);
}
Error Announcements
export function LiveRegion({ message }: { message: string }) {
return (
<div
role="alert"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
const [announcement, setAnnouncement] = useState('');
const handleSubmit = async () => {
try {
await submitForm();
setAnnouncement('Form submitted successfully');
} catch {
setAnnouncement('Error submitting form. Please try again.');
}
};
Images and Media
Image Alt Text
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024" />
<img src="decoration.svg" alt="" role="presentation" />
<figure>
<img src="infographic.png" alt="Company growth infographic" aria-describedby="infographic-desc" />
<figcaption id="infographic-desc">
Detailed description of the infographic...
</figcaption>
</figure>
Video Accessibility
<video controls>
<source src="video.mp4" type="video/mp4" />
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions" />
</video>
Screen Reader Utilities
Tailwind SR-Only Classes
.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;
}
.not-sr-only {
position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}
Screen Reader Only Text
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
return <span className="sr-only">{children}</span>;
}
<button>
<TrashIcon aria-hidden="true" />
<VisuallyHidden>Delete item</VisuallyHidden>
</button>
Testing Tools
Automated Testing
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('component has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Playwright a11y Testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage has no accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('keyboard navigation works', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const firstFocused = await page.evaluate(() => document.activeElement?.tagName);
expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocused);
await page.keyboard.press();
(page.()).();
});
Manual Testing Checklist
Best Practices
- Semantic HTML first: Use native elements before ARIA
- Focus management: Never remove focus outlines without replacement
- Announce changes: Use live regions for dynamic content
- Test with users: Include disabled users in testing
- Progressive enhancement: Core functionality without JavaScript
- Color independence: Don't rely on color alone for meaning
- Touch targets: Minimum 44x44px for mobile
- Animation: Respect
prefers-reduced-motion
Output Checklist
Every accessibility audit should verify: