Web accessibility audit workflow for WCAG compliance
Use this skill when auditing or improving web accessibility compliance.
| Level | Description | Target | |-------|-------------|--------| | A | Minimum accessibility | Required | | AA | Standard compliance | Recommended | | AAA | Enhanced accessibility | Ideal |
# Test without mouse:
# - Tab through all interactive elements
# - Shift+Tab to go backwards
# - Enter/Space to activate
# - Arrow keys for menus/selections
# - Escape to close modals
// ✅ All interactive elements must be focusable
<button onClick={handleClick}>Click me</button>
// ❌ Bad: Div with click handler
<div onClick={handleClick}>Click me</div>
// ✅ If you must use div, add proper attributes
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
Click me
</div>
# macOS: VoiceOver
# Cmd + F5 to toggle
# Ctrl + Option + arrows to navigate
# Windows: NVDA (free)
# Download from nvaccess.org
# Chrome: ChromeVox extension
/* Minimum contrast ratios */
/* Normal text (< 18px): 4.5:1 */
/* Large text (≥ 18px or 14px bold): 3:1 */
/* ❌ Bad: Low contrast */
.text {
color: #999;
background: #fff; /* 2.8:1 ratio */
}
/* ✅ Good: Sufficient contrast */
.text {
color: #595959;
background: #fff; /* 7:1 ratio */
}
// ❌ Bad: No alt text
<img src="product.jpg" />
// ✅ Good: Descriptive alt
<img src="product.jpg" alt="Red wireless headphones" />
// ✅ Good: Decorative image
<img src="divider.svg" alt="" role="presentation" />
// ✅ Good: Complex image
<figure>
<img src="chart.png" alt="Q4 sales chart" aria-describedby="chart-desc" />
<figcaption id="chart-desc">
Sales increased 45% from October to December,
with November showing the highest growth.
</figcaption>
</figure>
// ❌ Bad: No label
<input type="email" placeholder="Email" />
// ✅ Good: Visible label
<label htmlFor="email">Email address</label>
<input type="email" id="email" />
// ✅ Good: With error
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">
Please enter a valid email address
</span>
// ✅ Good: Required field
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
</label>
<input type="text" id="name" required aria-required="true" />
// ❌ Bad: Icon-only button
<button><Icon name="close" /></button>
// ✅ Good: With accessible name
<button aria-label="Close dialog">
<Icon name="close" aria-hidden="true" />
</button>
// ❌ Bad: Vague link
<a href="/docs">Click here</a>
// ✅ Good: Descriptive link
<a href="/docs">Read the documentation</a>
// ✅ Good: Link that opens new tab
<a href="/external" target="_blank" rel="noopener noreferrer">
External site
<span className="sr-only">(opens in new tab)</span>
</a>
// ❌ Bad: Skipped levels
<h1>Page Title</h1>
<h3>Section</h3> {/* Skipped h2! */}
// ✅ Good: Sequential levels
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
// ✅ Visible focus styles
button:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
// ✅ Focus trap in modal
import { FocusTrap } from '@headlessui/react';
function Modal({ children }) {
return (
<FocusTrap>
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Modal Title</h2>
{children}
</div>
</FocusTrap>
);
}
// ✅ Return focus after close
const triggerRef = useRef();
function openModal() {
triggerRef.current = document.activeElement;
setIsOpen(true);
}
function closeModal() {
setIsOpen(false);
triggerRef.current?.focus();
}
// Add at top of page
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<nav aria-label="Main navigation">...</nav>
<main id="main-content" tabIndex={-1}>
...
</main>
// CSS
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 50%;
transform: translateX(-50%);
top: 10px;
padding: 8px 16px;
background: #000;
color: #fff;
}
// Announce dynamic updates
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
// Urgent announcements
<div role="alert">
Error: Your session has expired.
</div>
// Loading states
function SearchResults({ isLoading, results }) {
return (
<>
<div aria-live="polite" className="sr-only">
{isLoading ? 'Loading results...' : `Found ${results.length} results`}
</div>
{/* Visual content */}
</>
);
}
# Automated testing
npm install @axe-core/react jest-axe
# In development (console warnings)
import React from 'react';
import ReactDOM from 'react-dom';
import axe from '@axe-core/react';
if (process.env.NODE_ENV !== 'production') {
axe(React, ReactDOM, 1000);
}
# In tests
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should have no a11y violations', async () => {
const { container } = render(<MyComponent />);
expect(await axe(container)).toHaveNoViolations();
});
| Tool | Purpose | |------|---------| | axe DevTools | Comprehensive testing | | WAVE | Visual evaluation | | HeadingsMap | Heading structure | | Landmarks | ARIA landmarks |
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
## Accessibility Audit
### Summary
- Issues found: 12
- Critical: 3
- Serious: 5
- Moderate: 4
### Critical Issues
#### Missing form labels
**Location**: Login form
**WCAG**: 1.3.1, 4.1.2
**Impact**: Screen reader users cannot identify fields
**Fix**:
```html
<label for="email">Email</label>
<input id="email" type="email">
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer