| name | accessibility |
| description | Audit and improve web accessibility following WCAG 2.1 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible". Do NOT use for SEO (use seo), performance (use core-web-vitals), or comprehensive site audits covering multiple areas (use web-quality-audit). |
| license | MIT |
| metadata | author: web-quality-skills version: '1.0' |
Accessibility (a11y)
Comprehensive accessibility guidelines based on WCAG 2.1 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities.
WCAG Principles: POUR
| Principle | Description |
|---|
| Perceivable | Content can be perceived through different senses |
| Operable | Interface can be operated by all users |
| Understandable | Content and interface are understandable |
| Robust | Content works with assistive technologies |
Conformance levels
| Level | Requirement | Target |
|---|
| A | Minimum accessibility | Must pass |
| AA | Standard compliance | Should pass (legal requirement in many jurisdictions) |
| AAA | Enhanced accessibility | Nice to have |
Perceivable
Text alternatives (1.1)
Images require alt text:
<img src="chart.png" />
<img src="chart.png" alt="Bar chart showing 40% increase in Q3 sales" />
<img src="decorative-border.png" alt="" role="presentation" />
<figure>
<img src="infographic.png" alt="2024 market trends infographic" aria-describedby="infographic-desc" />
<figcaption id="infographic-desc">
</figcaption>
</figure>
Icon buttons need accessible names:
<button>
<svg></svg>
</button>
<button aria-label="Open menu">
<svg aria-hidden="true"></svg>
</button>
<button>
<svg aria-hidden="true"></svg>
<span class="visually-hidden">Open menu</span>
</button>
Visually hidden class:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Color contrast (1.4.3, 1.4.6)
| Text Size | AA minimum | AAA enhanced |
|---|
| Normal text (< 18px / < 14px bold) | 4.5:1 | 7:1 |
| Large text (β₯ 18px / β₯ 14px bold) | 3:1 | 4.5:1 |
| UI components & graphics | 3:1 | 3:1 |
.low-contrast {
color: #999;
background: #fff;
}
.high-contrast {
color: #333;
background: #fff;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
Don't rely on color alone:
<input class="error-border" />
<style>
.error-border {
border-color: red;
}
</style>
<div class="field-error">
<input aria-invalid="true" aria-describedby="email-error" />
<span id="email-error" class="error-message">
<svg aria-hidden="true"></svg>
Please enter a valid email address
</span>
</div>
Media alternatives (1.2)
<video controls>
<source src="video.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" srclang="en" label="English" default />
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Descriptions" />
</video>
<audio controls>
<source src="podcast.mp3" type="audio/mp3" />
</audio>
<details>
<summary>Transcript</summary>
<p>Full transcript text...</p>
</details>
Operable
Keyboard accessible (2.1)
All functionality must be keyboard accessible:
element.addEventListener('click', handleAction)
element.addEventListener('click', handleAction)
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
})
No keyboard traps:
function openModal(modal) {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
if (e.key === 'Escape') {
closeModal()
}
})
firstElement.focus()
}
Focus visible (2.4.7)
*:focus {
outline: none;
}
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
button:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}
Skip links (2.4.1)
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header></header>
<main id="main-content" tabindex="-1">
</main>
</body>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
Timing (2.2)
function showSessionWarning() {
const modal = createModal({
title: 'Session Expiring',
content: 'Your session will expire in 2 minutes.',
actions: [
{ label: 'Extend session', action: extendSession },
{ label: 'Log out', action: logout },
],
timeout: 120000,
})
}
Motion (2.3)
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Understandable
Page language (3.1.1)
<html>
<html lang="en">
<p>The French word for hello is <span lang="fr">bonjour</span>.</p>
</html>
</html>
Consistent navigation (3.2.3)
<nav aria-label="Main">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
Form labels (3.3.2)
<input type="email" placeholder="Email" />
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" required />
<label>
Email address
<input type="email" name="email" autocomplete="email" required />
</label>
<label for="password">Password</label>
<input type="password" id="password" aria-describedby="password-requirements" />
<p id="password-requirements">Must be at least 8 characters with one number.</p>
Error handling (3.3.1, 3.3.3)
<form novalidate>
<div class="field" aria-live="polite">
<label for="email">Email</label>
<input type="email" id="email" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" class="error" role="alert">Please enter a valid email address (e.g., [email protected])</p>
</div>
</form>
form.addEventListener('submit', (e) => {
const firstError = form.querySelector('[aria-invalid="true"]')
if (firstError) {
e.preventDefault()
firstError.focus()
const errorSummary = document.getElementById('error-summary')
errorSummary.textContent = `${errors.length} errors found. Please fix them and try again.`
errorSummary.focus()
}
})
Robust
Valid HTML (4.1.1)
<div id="content">...</div>
<div id="content">...</div>
<a href="/"><button>Click</button></a>
<div id="main-content">...</div>
<div id="sidebar-content">...</div>
<a href="/" class="button-link">Click</a>
ARIA usage (4.1.2)
Prefer native elements:
<div role="button" tabindex="0">Click me</div>
<button>Click me</button>
<div role="checkbox" aria-checked="false">Option</div>
<label><input type="checkbox" /> Option</label>
When ARIA is needed:
<div role="tablist" aria-label="Product information">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">Description</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">Reviews</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
</div>
Live regions (4.1.3)
<div aria-live="polite" aria-atomic="true" class="status">
</div>
<div role="alert" aria-live="assertive">
</div>
function showNotification(message, type = 'polite') {
const container = document.getElementById(`${type}-announcer`)
container.textContent = ''
requestAnimationFrame(() => {
container.textContent = message
})
}
Testing checklist
Automated testing
npx lighthouse https://example.com --only-categories=accessibility
npm install @axe-core/cli -g
axe https://example.com
Manual testing
Screen reader commands
| Action | VoiceOver (Mac) | NVDA (Windows) |
|---|
| Start/Stop | β + F5 | Ctrl + Alt + N |
| Next item | VO + β | β |
| Previous item | VO + β | β |
| Activate | VO + Space | Enter |
| Headings list | VO + U, then arrows | H / Shift + H |
| Links list | VO + U | K / Shift + K |
Common issues by impact
Critical (fix immediately)
- Missing form labels
- Missing image alt text
- Insufficient color contrast
- Keyboard traps
- No focus indicators
Serious (fix before launch)
- Missing page language
- Missing heading structure
- Non-descriptive link text
- Auto-playing media
- Missing skip links
Moderate (fix soon)
- Missing ARIA labels on icons
- Inconsistent navigation
- Missing error identification
- Timing without controls
- Missing landmark regions
References