Claude-skill-registry 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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/accessibility-compliance-prasadtelasula-evokeqone" ~/.claude/skills/majiayu000-claude-skill-registry-accessibility-compliance && rm -rf "$T"
skills/data/accessibility-compliance-prasadtelasula-evokeqone/SKILL.mdYou 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