Agent skill
accessibility-compliance
Implement WCAG 2.1 AA accessibility compliance with ARIA labels, keyboard navigation, screen reader support, and color contrast. Use when ensuring accessibility or fixing a11y issues.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/testing/accessibility-compliance-aj-geddes-useful-ai-prompts
SKILL.md
You implement WCAG 2.1 AA accessibility compliance for the QA Team Portal.
Requirements from PROJECT_PLAN.md
- Standard: WCAG 2.1 AA compliance
- Keyboard navigation support
- Screen reader compatibility
- Color contrast standards (4.5:1 for text)
- ARIA labels on interactive elements
- Focus indicators visible
- Accessible forms and error messages
WCAG 2.1 AA Requirements
Perceivable
- Text alternatives for non-text content
- Captions for audio/video
- Content can be presented in different ways
- Color contrast minimum 4.5:1 (text), 3:1 (large text, UI components)
Operable
- Keyboard accessible (all functionality)
- Enough time to read/use content
- No content that causes seizures (flashing < 3 times per second)
- Navigation and finding content
Understandable
- Readable and understandable text
- Predictable operation
- Input assistance (labels, error messages)
Robust
- Compatible with assistive technologies
- Valid HTML
- Name, role, value for UI components
Implementation
1. Semantic HTML
Use proper HTML5 elements:
// ❌ Wrong: Divs for everything
<div className="button" onClick={handleClick}>Click me</div>
<div className="nav">
<div>Home</div>
<div>About</div>
</div>
// ✅ Correct: Semantic elements
<button onClick={handleClick}>Click me</button>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
// ✅ Proper document structure
<header>
<nav>...</nav>
</header>
<main>
<article>
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
<p>Content</p>
</section>
</article>
</main>
<footer>...</footer>
2. ARIA Labels and Roles
Location: frontend/src/components/Navigation.tsx
export const Navigation = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<header role="banner">
<nav role="navigation" aria-label="Main navigation">
<div className="container">
<a href="/" aria-label="QA Team Portal Home">
<img src="/logo.svg" alt="Evoke Logo" />
<span>QA Team Portal</span>
</a>
{/* Desktop Menu */}
<ul role="menubar" className="hidden md:flex">
<li role="none">
<a href="#team" role="menuitem">Team</a>
</li>
<li role="none">
<a href="#updates" role="menuitem">Updates</a>
</li>
<li role="none">
<a href="#tools" role="menuitem">Tools</a>
</li>
</ul>
{/* Mobile Menu Toggle */}
<button
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
>
{mobileMenuOpen ? <X /> : <Menu />}
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div
id="mobile-menu"
role="menu"
aria-label="Mobile navigation"
>
<a href="#team" role="menuitem">Team</a>
<a href="#updates" role="menuitem">Updates</a>
<a href="#tools" role="menuitem">Tools</a>
</div>
)}
</nav>
</header>
)
}
3. Keyboard Navigation
Focus Management:
// frontend/src/components/Modal.tsx
import { useEffect, useRef } from 'react'
export const Modal = ({ isOpen, onClose, children }) => {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Store previous focus
previousFocusRef.current = document.activeElement as HTMLElement
// Focus first focusable element in modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusableElements && focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus()
}
// Trap focus inside modal
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const focusableContent = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusableContent || focusableContent.length === 0) return
const firstElement = focusableContent[0] as HTMLElement
const lastElement = focusableContent[focusableContent.length - 1] as HTMLElement
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus()
e.preventDefault()
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus()
e.preventDefault()
}
}
}
document.addEventListener('keydown', handleTab)
return () => {
document.removeEventListener('keydown', handleTab)
// Restore previous focus
previousFocusRef.current?.focus()
}
}
}, [isOpen])
// Close on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
className="fixed inset-0 z-50"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Content */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h2 id="modal-title" className="text-2xl font-bold mb-4">
Modal Title
</h2>
{children}
<button onClick={onClose} className="mt-4">
Close
</button>
</div>
</div>
</div>
)
}
Skip to Main Content:
// frontend/src/components/SkipToContent.tsx
export const SkipToContent = () => {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-white focus:rounded"
>
Skip to main content
</a>
)
}
// Usage in App.tsx
<SkipToContent />
<Header />
<main id="main-content">
{/* Page content */}
</main>
4. Form Accessibility
Accessible Form:
// frontend/src/components/forms/AccessibleForm.tsx
export const LoginForm = () => {
const [errors, setErrors] = useState<Record<string, string>>({})
return (
<form onSubmit={handleSubmit} noValidate>
<div className="space-y-4">
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium mb-2"
>
Email <span aria-label="required" className="text-error">*</span>
</label>
<input
id="email"
name="email"
type="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
className={cn(
"w-full px-3 py-2 border rounded-lg",
errors.email ? "border-error" : "border-input"
)}
/>
{errors.email && (
<p
id="email-error"
role="alert"
className="text-sm text-error mt-1"
>
{errors.email}
</p>
)}
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-2"
>
Password <span aria-label="required" className="text-error">*</span>
</label>
<input
id="password"
name="password"
type="password"
required
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error password-requirements" : "password-requirements"}
className={cn(
"w-full px-3 py-2 border rounded-lg",
errors.password ? "border-error" : "border-input"
)}
/>
<p id="password-requirements" className="text-xs text-muted-foreground mt-1">
Password must be at least 12 characters
</p>
{errors.password && (
<p
id="password-error"
role="alert"
className="text-sm text-error mt-1"
>
{errors.password}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
className="w-full bg-primary text-white py-2 px-4 rounded-lg hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
Sign In
</button>
</div>
{/* Form-level error */}
{errors.form && (
<div
role="alert"
aria-live="assertive"
className="mt-4 p-3 bg-error-light text-error rounded-lg"
>
{errors.form}
</div>
)}
</form>
)
}
5. Focus Indicators
Custom Focus Styles:
/* frontend/src/index.css */
/* Remove default outline and add custom focus ring */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Button focus styles */
button:focus-visible,
a:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Input focus styles */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-color: hsl(var(--ring));
}
/* Skip to content link */
.skip-to-content:focus {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 9999;
padding: 0.75rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: 0.375rem;
}
6. Color Contrast
Check and Fix Contrast:
// Use colors that meet WCAG AA standards
// ❌ Bad: Low contrast (2.5:1)
<p className="text-gray-400 bg-gray-200">Low contrast text</p>
// ✅ Good: High contrast (4.5:1+)
<p className="text-gray-900 bg-gray-100">High contrast text</p>
// ✅ Good: Using theme colors with proper contrast
<p className="text-foreground bg-background">Theme colors</p>
<button className="bg-primary text-primary-foreground">Button</button>
// For links, ensure visible distinction
<a href="#" className="text-primary underline hover:text-primary/90">
Link text
</a>
Contrast Checker Function:
// frontend/src/utils/colorContrast.ts
export const getContrastRatio = (color1: string, color2: string): number => {
const getLuminance = (color: string) => {
// Convert hex to RGB
const rgb = parseInt(color.slice(1), 16)
const r = (rgb >> 16) & 0xff
const g = (rgb >> 8) & 0xff
const b = (rgb >> 0) & 0xff
// Calculate relative luminance
const [rs, gs, bs] = [r, g, b].map(c => {
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 lum1 = getLuminance(color1)
const lum2 = getLuminance(color2)
const lighter = Math.max(lum1, lum2)
const darker = Math.min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
}
export const meetsWCAGAA = (color1: string, color2: string, isLargeText: boolean = false): boolean => {
const contrast = getContrastRatio(color1, color2)
return isLargeText ? contrast >= 3 : contrast >= 4.5
}
// Usage
console.log(meetsWCAGAA('#0066CC', '#FFFFFF')) // true (7.4:1)
console.log(meetsWCAGAA('#808080', '#FFFFFF')) // false (3.9:1)
7. Images and Alt Text
// ❌ Bad: Missing alt text
<img src="/team/john.jpg" />
// ✅ Good: Descriptive alt text
<img src="/team/john.jpg" alt="John Doe, Senior QA Engineer" />
// ✅ Decorative images
<img src="/decoration.svg" alt="" aria-hidden="true" />
// ✅ Complex images with longer descriptions
<figure>
<img
src="/chart.png"
alt="Bar chart showing test coverage by module"
aria-describedby="chart-desc"
/>
<figcaption id="chart-desc">
The chart shows test coverage percentages for each module:
Authentication (95%), User Management (88%), Reports (76%),
Settings (92%).
</figcaption>
</figure>
8. Live Regions for Dynamic Content
// frontend/src/components/StatusMessage.tsx
export const StatusMessage = ({ message, type }: { message: string; type: 'success' | 'error' | 'info' }) => {
return (
<div
role="status"
aria-live={type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
className={cn(
"p-4 rounded-lg",
{
'bg-success-light text-success': type === 'success',
'bg-error-light text-error': type === 'error',
'bg-blue-50 text-blue-900': type === 'info',
}
)}
>
{message}
</div>
)
}
// Usage
<StatusMessage
message="Team member created successfully"
type="success"
/>
9. Accessible Data Tables
// frontend/src/components/admin/AccessibleTable.tsx
export const TeamMembersTable = ({ members }: { members: TeamMember[] }) => {
return (
<table role="table" aria-label="Team members list">
<caption className="sr-only">
List of {members.length} team members
</caption>
<thead>
<tr>
<th scope="col">Photo</th>
<th scope="col">Name</th>
<th scope="col">Role</th>
<th scope="col">Email</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{members.map((member, index) => (
<tr key={member.id}>
<td>
<img
src={member.photo_url}
alt={`${member.name}'s profile photo`}
className="w-12 h-12 rounded-full"
/>
</td>
<th scope="row">{member.name}</th>
<td>{member.role}</td>
<td>{member.email}</td>
<td>
<button
aria-label={`Edit ${member.name}`}
className="mr-2"
>
Edit
</button>
<button
aria-label={`Delete ${member.name}`}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
10. Screen Reader Only Text
/* frontend/src/index.css */
/* Screen reader only class */
.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;
}
/* Show on focus (for skip links) */
.sr-only:focus {
position: static;
width: auto;
height: auto;
padding: initial;
margin: initial;
overflow: visible;
clip: auto;
white-space: normal;
}
// Usage
<span className="sr-only">Current page</span>
<button aria-label="Close menu">
<X aria-hidden="true" />
<span className="sr-only">Close</span>
</button>
Accessibility Testing
1. Automated Testing with axe-core
cd frontend
npm install -D @axe-core/playwright
# tests/e2e/test_accessibility.py
from axe_playwright_python import Axe
def test_homepage_accessibility(page):
"""Test homepage accessibility."""
page.goto('http://localhost:5173')
# Run axe accessibility scan
axe = Axe()
results = axe.run(page)
violations = results['violations']
if violations:
print(f"\nFound {len(violations)} accessibility violations:\n")
for violation in violations:
print(f"❌ {violation['id']}: {violation['description']}")
print(f" Impact: {violation['impact']}")
print(f" Help: {violation['helpUrl']}")
print(f" Affected nodes: {len(violation['nodes'])}\n")
# Assert no violations
assert len(violations) == 0, f"Found {len(violations)} accessibility violations"
def test_admin_login_accessibility(page):
"""Test login form accessibility."""
page.goto('http://localhost:5173/admin/login')
axe = Axe()
results = axe.run(page)
assert len(results['violations']) == 0
2. Keyboard Navigation Testing
# tests/e2e/test_keyboard_navigation.py
def test_keyboard_navigation(page):
"""Test keyboard navigation through the page."""
page.goto('http://localhost:5173')
# Start from top
page.keyboard.press('Tab')
# Should focus skip link first
expect(page.locator('.skip-to-content')).to_be_focused()
# Tab through navigation
page.keyboard.press('Tab')
expect(page.locator('nav a:nth-child(1)')).to_be_focused()
# Test Enter key activation
page.keyboard.press('Enter')
# Should navigate
def test_modal_focus_trap(page):
"""Test focus is trapped inside modal."""
page.goto('http://localhost:5173/admin/team-members')
# Open modal
page.click('button:has-text("Add Team Member")')
# Tab through all focusable elements
# Last Tab should cycle back to first element
for _ in range(10):
page.keyboard.press('Tab')
# Focus should still be inside modal
assert page.locator('[role="dialog"]').evaluate('el => el.contains(document.activeElement)')
# Escape should close modal
page.keyboard.press('Escape')
expect(page.locator('[role="dialog"]')).not_to_be_visible()
3. Screen Reader Testing
Test with actual screen readers:
- macOS: VoiceOver (Cmd+F5)
- Windows: NVDA (free), JAWS (paid)
- Linux: Orca
Test checklist:
- All images have appropriate alt text
- All form inputs have labels
- Error messages are announced
- Dynamic content changes are announced (aria-live)
- Headings structure is logical
- Landmarks are properly identified (header, nav, main, footer)
- Lists are properly marked up
4. Color Contrast Testing
# Install contrast checker
npm install -D axe-core
# Run contrast check
npx axe http://localhost:5173 --rules=color-contrast
WCAG 2.1 AA Checklist
Perceivable
- All images have alt text
- Videos have captions (if applicable)
- Color is not the only means of conveying information
- Text contrast >= 4.5:1 (normal), >= 3:1 (large text 18pt+)
- Text can be resized to 200% without loss of content
- Images of text avoided (use real text)
Operable
- All functionality available via keyboard
- No keyboard trap
- Skip to main content link present
- Page titles are descriptive
- Link purpose clear from link text or context
- Multiple ways to find pages (navigation, search, sitemap)
- Headings and labels are descriptive
- Focus indicator visible
- No time limits (or user can extend)
- No content flashing more than 3 times per second
Understandable
- Language of page declared (html lang="en")
- Language of parts declared if different
- Navigation is consistent across pages
- Labels or instructions provided for user input
- Error messages are clear and helpful
- Error prevention for important actions (confirmation)
- Form fields have visible labels
- Required fields are indicated
Robust
- HTML validates (use W3C validator)
- Name, role, value available for all UI components
- Status messages programmatically determinable (aria-live)
- Works with assistive technologies
Accessibility Resources
Tools:
- axe DevTools: Browser extension for accessibility testing
- Lighthouse: Built into Chrome DevTools
- WAVE: Web accessibility evaluation tool
- Color Contrast Analyzer: Check color combinations
- Screen readers: NVDA (Windows), VoiceOver (macOS), JAWS (Windows)
Documentation:
- WCAG 2.1: https://www.w3.org/WAI/WCAG21/quickref/
- ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- MDN Accessibility: https://developer.mozilla.org/en-US/docs/Web/Accessibility
Report
✅ WCAG 2.1 AA compliance achieved ✅ All images have descriptive alt text ✅ Semantic HTML used throughout ✅ ARIA labels added to interactive elements ✅ Keyboard navigation fully functional ✅ Focus indicators visible and clear ✅ Color contrast meets 4.5:1 minimum ✅ Forms fully accessible with proper labels ✅ Screen reader tested (VoiceOver/NVDA) ✅ Skip to content link implemented ✅ No accessibility violations found (axe-core) ✅ Automated tests passing
Didn't find tool you were looking for?