---
name: auditing-accessibility-wcag
description: Checks components and pages for WCAG 2.1 accessibility violations. Use when the user asks about a11y, WCAG compliance, screen readers, aria labels, keyboard navigation, or accessible patterns.
---
# Accessibility Auditor (WCAG 2.1)
## When to use this skill
- User asks about accessibility or a11y
- User mentions WCAG or compliance levels
- User wants to audit a component or page
- User asks about aria labels or roles
- User needs accessible alternative patterns
## Workflow
- [ ] Identify scope (component, page, site)
- [ ] Run automated audits
- [ ] Review manual checkpoints
- [ ] Categorize violations by severity
- [ ] Provide remediation examples
- [ ] Validate fixes
## Instructions
### Step 1: Run Automated Audits
**axe-core (CLI):**
```bash
npm install -D @axe-core/cli
npx axe http://localhost:3000 --exit
```
**Lighthouse accessibility audit:**
```bash
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json
```
**ESLint accessibility plugin:**
```bash
npm install -D eslint-plugin-jsx-a11y
```
```javascript
// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [jsxA11y.flatConfigs.recommended];
```
### Step 2: WCAG 2.1 AA Checklist
| Principle | Guideline | Common Issues |
| -------------- | --------------------- | --------------------------- |
| Perceivable | 1.1 Text Alternatives | Missing alt text |
| Perceivable | 1.3 Adaptable | Improper heading order |
| Perceivable | 1.4 Distinguishable | Low color contrast |
| Operable | 2.1 Keyboard | No focus styles |
| Operable | 2.4 Navigable | Missing skip links |
| Understandable | 3.1 Readable | Missing lang attribute |
| Understandable | 3.2 Predictable | Unexpected focus changes |
| Robust | 4.1 Compatible | Invalid HTML, missing roles |
### Step 3: Common Violations & Fixes
**Missing alt text:**
```tsx
// ❌ Violation
// ✅ Fixed - Informative image
// ✅ Fixed - Decorative image
```
**Low color contrast (4.5:1 minimum for AA):**
```css
/* ❌ Violation - 2.5:1 ratio */
.text-muted {
color: #999999;
background: #ffffff;
}
/* ✅ Fixed - 4.6:1 ratio */
.text-muted {
color: #767676;
background: #ffffff;
}
```
**Missing form labels:**
```tsx
// ❌ Violation
// ✅ Fixed - Visible label
// ✅ Fixed - Visually hidden label
```
**Missing focus indicators:**
```css
/* ❌ Violation */
button:focus {
outline: none;
}
/* ✅ Fixed */
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
```
**Improper heading hierarchy:**
```tsx
// ❌ Violation - Skips h2
Page Title
Section Title
// ✅ Fixed
Page Title
Section Title
```
**Non-semantic buttons:**
```tsx
// ❌ Violation
Click me
// ✅ Fixed
```
**Missing skip link:**
```tsx
// ✅ Add at top of page
Skip to main content
// ... header/nav ...
{/* Page content */}
```
```css
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
padding: 1rem;
background: var(--color-background);
color: var(--color-text);
}
.skip-link:focus {
left: 1rem;
top: 1rem;
}
```
### Step 4: Interactive Component Patterns
**Accessible modal:**
```tsx
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 closeButtonRef = useRef(null);
const modalRef = useRef(null);
useEffect(() => {
if (isOpen) {
closeButtonRef.current?.focus();
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Trap focus inside modal
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Tab") {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (!focusable?.length) 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();
}
}
};
if (!isOpen) return null;
return (
);
}
```
**Accessible dropdown menu:**
```tsx
import { useState, useRef } from "react";
export function Dropdown({ label, items }: { label: string; items: string[] }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const buttonRef = useRef(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Escape":
setIsOpen(false);
buttonRef.current?.focus();
break;
case "Enter":
case " ":
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
}
break;
}
};
return (
{isOpen && (
{items.map((item, i) => (
-
{item}
))}
)}
);
}
```
### Step 5: Testing Tools
**Screen reader testing:**
- macOS: VoiceOver (Cmd+F5)
- Windows: NVDA (free) or JAWS
- Browser: ChromeVox extension
**Browser extensions:**
- axe DevTools
- WAVE Evaluation Tool
- Accessibility Insights
**Keyboard testing checklist:**
- [ ] All interactive elements reachable via Tab
- [ ] Focus order matches visual order
- [ ] Focus visible on all elements
- [ ] Escape closes modals/dropdowns
- [ ] Arrow keys navigate within components
### Step 6: Audit Report Template
```markdown
## Accessibility Audit Report
**URL**: https://example.com
**Date**: 2026-01-18
**Standard**: WCAG 2.1 AA
**Tools**: axe-core, Lighthouse, manual testing
### Summary
| Severity | Count |
| -------- | ----- |
| Critical | 2 |
| Serious | 5 |
| Moderate | 8 |
| Minor | 3 |
### Critical Issues
#### 1. Images missing alt text
- **WCAG**: 1.1.1 Non-text Content
- **Location**: Homepage hero, product cards
- **Impact**: Screen reader users cannot understand image content
- **Fix**: Add descriptive alt text to all informative images
#### 2. Form inputs missing labels
- **WCAG**: 1.3.1 Info and Relationships
- **Location**: Contact form, search box
- **Impact**: Users cannot identify form field purpose
- **Fix**: Associate `