packages/skills-catalog/skills/(quality)/web-accessibility/SKILL.md
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).
npx skillsauth add tech-leads-club/agent-skills accessibilityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Comprehensive accessibility guidelines based on WCAG 2.1 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities.
| 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 |
| Level | Requirement | Target | | ------- | ---------------------- | ----------------------------------------------------- | | A | Minimum accessibility | Must pass | | AA | Standard compliance | Should pass (legal requirement in many jurisdictions) | | AAA | Enhanced accessibility | Nice to have |
Images require alt text:
<!-- ❌ Missing alt -->
<img src="chart.png" />
<!-- ✅ Descriptive alt -->
<img src="chart.png" alt="Bar chart showing 40% increase in Q3 sales" />
<!-- ✅ Decorative image (empty alt) -->
<img src="decorative-border.png" alt="" role="presentation" />
<!-- ✅ Complex image with longer description -->
<figure>
<img src="infographic.png" alt="2024 market trends infographic" aria-describedby="infographic-desc" />
<figcaption id="infographic-desc">
<!-- Detailed description -->
</figcaption>
</figure>
Icon buttons need accessible names:
<!-- ❌ No accessible name -->
<button>
<svg><!-- menu icon --></svg>
</button>
<!-- ✅ Using aria-label -->
<button aria-label="Open menu">
<svg aria-hidden="true"><!-- menu icon --></svg>
</button>
<!-- ✅ Using visually hidden text -->
<button>
<svg aria-hidden="true"><!-- menu icon --></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;
}
| 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 (2.5:1) */
.low-contrast {
color: #999;
background: #fff;
}
/* ✅ Sufficient contrast (7:1) */
.high-contrast {
color: #333;
background: #fff;
}
/* ✅ Focus states need contrast too */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
Don't rely on color alone:
<!-- ❌ Only color indicates error -->
<input class="error-border" />
<style>
.error-border {
border-color: red;
}
</style>
<!-- ✅ Color + icon + text -->
<div class="field-error">
<input aria-invalid="true" aria-describedby="email-error" />
<span id="email-error" class="error-message">
<svg aria-hidden="true"><!-- error icon --></svg>
Please enter a valid email address
</span>
</div>
<!-- Video with captions -->
<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 with transcript -->
<audio controls>
<source src="podcast.mp3" type="audio/mp3" />
</audio>
<details>
<summary>Transcript</summary>
<p>Full transcript text...</p>
</details>
All functionality must be keyboard accessible:
// ❌ Only handles click
element.addEventListener('click', handleAction)
// ✅ Handles both click and keyboard
element.addEventListener('click', handleAction)
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
})
No keyboard traps:
// Modal focus management
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]
// Trap focus within modal
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()
}
/* ❌ Never remove focus outlines */
*:focus {
outline: none;
}
/* ✅ Use :focus-visible for keyboard-only focus */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* ✅ Or custom focus styles */
button:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="main-content" tabindex="-1">
<!-- main content -->
</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;
}
// Allow users to extend time limits
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, // 2 minutes to respond
})
}
/* Respect reduced motion preference */
@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;
}
}
<!-- ❌ No language specified -->
<html>
<!-- ✅ Language specified -->
<html lang="en">
<!-- ✅ Language changes within page -->
<p>The French word for hello is <span lang="fr">bonjour</span>.</p>
</html>
</html>
<!-- Navigation should be consistent across pages -->
<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>
<!-- ❌ No label association -->
<input type="email" placeholder="Email" />
<!-- ✅ Explicit label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" required />
<!-- ✅ Implicit label -->
<label>
Email address
<input type="email" name="email" autocomplete="email" required />
</label>
<!-- ✅ With instructions -->
<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>
<!-- Announce errors to screen readers -->
<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>
// Focus first error on submit
form.addEventListener('submit', (e) => {
const firstError = form.querySelector('[aria-invalid="true"]')
if (firstError) {
e.preventDefault()
firstError.focus()
// Announce error summary
const errorSummary = document.getElementById('error-summary')
errorSummary.textContent = `${errors.length} errors found. Please fix them and try again.`
errorSummary.focus()
}
})
<!-- ❌ Duplicate IDs -->
<div id="content">...</div>
<div id="content">...</div>
<!-- ❌ Invalid nesting -->
<a href="/"><button>Click</button></a>
<!-- ✅ Unique IDs -->
<div id="main-content">...</div>
<div id="sidebar-content">...</div>
<!-- ✅ Proper nesting -->
<a href="/" class="button-link">Click</a>
Prefer native elements:
<!-- ❌ ARIA role on div -->
<div role="button" tabindex="0">Click me</div>
<!-- ✅ Native button -->
<button>Click me</button>
<!-- ❌ ARIA checkbox -->
<div role="checkbox" aria-checked="false">Option</div>
<!-- ✅ Native checkbox -->
<label><input type="checkbox" /> Option</label>
When ARIA is needed:
<!-- Custom tabs component -->
<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">
<!-- Panel content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<!-- Panel content -->
</div>
<!-- Status updates -->
<div aria-live="polite" aria-atomic="true" class="status">
<!-- Content updates announced to screen readers -->
</div>
<!-- Urgent alerts -->
<div role="alert" aria-live="assertive">
<!-- Interrupts current announcement -->
</div>
// Announce dynamic content changes
function showNotification(message, type = 'polite') {
const container = document.getElementById(`${type}-announcer`)
container.textContent = '' // Clear first
requestAnimationFrame(() => {
container.textContent = message
})
}
# Lighthouse accessibility audit
npx lighthouse https://example.com --only-categories=accessibility
# axe-core
npm install @axe-core/cli -g
axe https://example.com
prefers-reduced-motion: reduce| 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 |
development
Generate Excalidraw diagrams from natural language descriptions. Outputs .excalidraw JSON files openable in Excalidraw. Use when asked to "create a diagram", "make a flowchart", "visualize a process", "draw a system architecture", "create a mind map", "generate an Excalidraw file", "draw an ER diagram", "create a sequence diagram", or "make a class diagram". Supports flowcharts, relationship diagrams, mind maps, architecture, DFD, swimlane, class, sequence, and ER diagrams. Can use icon libraries (AWS, GCP, etc.) when set up. Do NOT use for code architecture analysis (use the architecture skills), Mermaid diagram rendering (use mermaid-studio), or non-visual documentation (use docs-writer).
tools
Browser debugging, performance profiling, and automation via Chrome DevTools MCP. Use when user says "debug this page", "take a screenshot", "check network requests", "profile performance", "inspect console errors", or "analyze page load". Do NOT use for full E2E test suites (use playwright-skill) or non-browser debugging.
development
Repository-grounded threat modeling that enumerates trust boundaries, assets, attacker capabilities, abuse paths, and mitigations, and writes a concise Markdown threat model. Use when the user asks to threat model a codebase or path, enumerate threats or abuse paths, or perform AppSec threat modeling. Do NOT use for general architecture summaries, code review, security best practices (use security-best-practices), or non-security design work.
development
Analyze git repositories to build a security ownership topology (people-to-file), compute bus factor and sensitive-code ownership, and export CSV/JSON for graph databases and visualization. Use when the user explicitly wants a security-oriented ownership or bus-factor analysis grounded in git history (for example: orphaned sensitive code, security maintainers, CODEOWNERS reality checks for risk, sensitive hotspots, or ownership clusters). Do NOT use for general maintainer lists, non-security ownership questions, or threat modeling (use security-threat-model).