Claude-skill-registry a11y-specialist
Expert in web accessibility (WCAG 2.1/2.2 AA/AAA compliance), ARIA patterns, keyboard navigation, screen reader testing, color contrast, focus management, and automated accessibility testing
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/a11y-specialist" ~/.claude/skills/majiayu000-claude-skill-registry-a11y-specialist && rm -rf "$T"
manifest:
skills/data/a11y-specialist/SKILL.mdsource content
Accessibility Specialist
Expert skill for building accessible web applications that comply with WCAG 2.1/2.2 standards. Specializes in ARIA patterns, keyboard navigation, screen reader optimization, automated testing, and inclusive design principles.
Core Capabilities
1. WCAG Compliance
- Level A: Basic accessibility (must have)
- Level AA: Mid-level accessibility (should have) - Industry standard
- Level AAA: Enhanced accessibility (nice to have)
- WCAG 2.1: Mobile, low vision, cognitive additions
- WCAG 2.2: Latest standards (focus appearance, dragging)
- Section 508: US federal requirements
- ADA: Americans with Disabilities Act compliance
2. ARIA Patterns
- Roles: Button, dialog, menu, tablist, combobox, listbox
- States: aria-expanded, aria-selected, aria-checked, aria-pressed
- Properties: aria-label, aria-labelledby, aria-describedby
- Live Regions: aria-live, aria-atomic, aria-relevant
- Relationships: aria-owns, aria-controls, aria-flowto
- Patterns: WAI-ARIA Authoring Practices Guide (APG)
3. Keyboard Navigation
- Tab Order: Logical focus order
- Focus Management: Focus trap, focus restoration
- Keyboard Shortcuts: Arrow keys, Enter, Space, Escape
- Skip Links: Skip to main content
- Focus Indicators: Visible focus styles
- Roving Tabindex: Composite widgets
4. Screen Reader Support
- Semantic HTML: Use correct elements
- Alt Text: Descriptive image alternatives
- Heading Structure: Logical h1-h6 hierarchy
- Landmark Regions: header, nav, main, aside, footer
- Announcements: Dynamic content updates
- Hidden Content: aria-hidden, sr-only classes
5. Visual Accessibility
- Color Contrast: WCAG AA (4.5:1 text, 3:1 UI)
- Color Independence: Don't rely on color alone
- Text Sizing: Relative units (rem, em)
- Spacing: Touch targets 44×44px minimum
- Motion: Respect prefers-reduced-motion
- Zoom: Support 200% zoom
6. Automated Testing
- axe-core: Industry standard accessibility engine
- jest-axe: Jest integration for unit tests
- Lighthouse: Google accessibility audits
- Pa11y: CI/CD accessibility testing
- Testing Library: Built-in accessibility queries
- ESLint: jsx-a11y plugin
7. Manual Testing
- Keyboard Only: Test without mouse
- Screen Readers: NVDA, JAWS, VoiceOver
- Browser DevTools: Accessibility tree inspection
- Color Blindness: Simulators
- Zoom Testing: 200% zoom verification
- User Testing: Real users with disabilities
Workflow
Phase 1: Accessibility Planning
-
Define Requirements
- WCAG level target (A, AA, AAA)?
- Legal requirements (Section 508, ADA)?
- User personas with disabilities?
- Priority features for accessibility?
-
Audit Existing Code
- Run automated tools (axe, Lighthouse)
- Manual keyboard testing
- Screen reader testing
- Color contrast checks
-
Create Action Plan
- Categorize issues (critical, high, medium, low)
- Estimate effort
- Prioritize fixes
- Set timeline
Phase 2: Implementation
-
Semantic HTML
- Use correct elements
- Add ARIA only when needed
- Structure content logically
- Use landmarks
-
Keyboard Support
- Add keyboard handlers
- Manage focus
- Implement roving tabindex
- Add skip links
-
Screen Reader Support
- Add ARIA labels
- Implement live regions
- Test with real screen readers
- Fix announced text
-
Visual Accessibility
- Fix color contrast
- Add focus indicators
- Ensure text sizing
- Test with zoom
Phase 3: Testing & Maintenance
-
Automated Testing
- Unit tests with jest-axe
- Integration tests
- CI/CD pipeline checks
- Regular audits
-
Manual Testing
- Keyboard navigation
- Screen reader testing
- User testing
- Browser compatibility
-
Documentation
- Accessibility statement
- Known issues
- Usage guidelines
- Testing procedures
Accessibility Patterns
Accessible Button
// AccessibleButton.tsx import { forwardRef, ButtonHTMLAttributes } from 'react' interface AccessibleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { /** * Accessible label for screen readers * Required if button contains only an icon */ 'aria-label'?: string /** * ID of element that labels this button */ 'aria-labelledby'?: string /** * ID of element that describes this button */ 'aria-describedby'?: string /** * Loading state (shows spinner, disables interaction) */ loading?: boolean } export const AccessibleButton = forwardRef<HTMLButtonElement, AccessibleButtonProps>( ({ children, loading, disabled, 'aria-label': ariaLabel, ...props }, ref) => { return ( <button ref={ref} type="button" disabled={disabled || loading} aria-label={ariaLabel} aria-busy={loading} aria-disabled={disabled || loading} {...props} > {loading && ( <span className="spinner" aria-hidden="true"> {/* Spinner icon */} </span> )} {children} {loading && <span className="sr-only">Loading...</span>} </button> ) } ) AccessibleButton.displayName = 'AccessibleButton' // Usage <AccessibleButton onClick={handleClick}> Save Changes </AccessibleButton> // Icon-only button (MUST have aria-label) <AccessibleButton aria-label="Close dialog" onClick={onClose}> <XIcon aria-hidden="true" /> </AccessibleButton>
Accessible Modal/Dialog
// AccessibleModal.tsx import { useEffect, useRef, ReactNode } from 'react' import { createPortal } from 'react-dom' import { useFocusTrap } from './hooks/useFocusTrap' import { useEscapeKey } from './hooks/useEscapeKey' interface AccessibleModalProps { isOpen: boolean onClose: () => void title: string children: ReactNode /** * ID for the modal title (for aria-labelledby) */ titleId?: string /** * ID for the modal description (for aria-describedby) */ descriptionId?: string } export function AccessibleModal({ isOpen, onClose, title, children, titleId = 'modal-title', descriptionId, }: AccessibleModalProps) { const modalRef = useRef<HTMLDivElement>(null) const previousActiveElement = useRef<HTMLElement | null>(null) // Trap focus inside modal useFocusTrap(modalRef, isOpen) // Close on Escape useEscapeKey(onClose, isOpen) useEffect(() => { if (isOpen) { // Store currently focused element previousActiveElement.current = document.activeElement as HTMLElement // Prevent body scroll document.body.style.overflow = 'hidden' // Focus modal modalRef.current?.focus() } else { // Restore body scroll document.body.style.overflow = '' // Restore focus to previous element previousActiveElement.current?.focus() } return () => { document.body.style.overflow = '' } }, [isOpen]) if (!isOpen) return null return createPortal( <div className="modal-overlay" onClick={onClose} role="presentation" > <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId} className="modal-content" onClick={(e) => e.stopPropagation()} tabIndex={-1} > <div className="modal-header"> <h2 id={titleId}>{title}</h2> <button onClick={onClose} aria-label="Close dialog" className="modal-close" > <span aria-hidden="true">×</span> </button> </div> <div className="modal-body" id={descriptionId}> {children} </div> </div> </div>, document.body ) } // Usage <AccessibleModal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Confirm Action" descriptionId="modal-desc" > <p id="modal-desc">Are you sure you want to delete this item?</p> <button onClick={handleConfirm}>Confirm</button> <button onClick={() => setIsOpen(false)}>Cancel</button> </AccessibleModal>
Focus Trap Hook
// hooks/useFocusTrap.ts import { useEffect, RefObject } from 'react' const FOCUSABLE_ELEMENTS = [ 'a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(', ') export function useFocusTrap(ref: RefObject<HTMLElement>, isActive: boolean) { useEffect(() => { if (!isActive) return const element = ref.current if (!element) return const focusableElements = element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS) const firstFocusable = focusableElements[0] const lastFocusable = focusableElements[focusableElements.length - 1] const handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstFocusable) { lastFocusable?.focus() e.preventDefault() } } else { // Tab if (document.activeElement === lastFocusable) { firstFocusable?.focus() e.preventDefault() } } } element.addEventListener('keydown', handleTabKey) return () => { element.removeEventListener('keydown', handleTabKey) } }, [ref, isActive]) }
Accessible Form with Live Validation
// AccessibleForm.tsx import { useState, useId, FormEvent } from 'react' export function AccessibleForm() { const [email, setEmail] = useState('') const [error, setError] = useState('') const [success, setSuccess] = useState('') const emailId = useId() const errorId = useId() const successId = useId() const validateEmail = (value: string) => { if (!value) { setError('Email is required') return false } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { setError('Please enter a valid email address') return false } setError('') return true } const handleSubmit = (e: FormEvent) => { e.preventDefault() if (validateEmail(email)) { setSuccess('Form submitted successfully!') // Submit form } } return ( <form onSubmit={handleSubmit} noValidate> <div className="form-field"> <label htmlFor={emailId}> Email Address <span aria-label="required">*</span> </label> <input id={emailId} type="email" value={email} onChange={(e) => setEmail(e.target.value)} onBlur={() => validateEmail(email)} aria-invalid={!!error} aria-describedby={error ? errorId : undefined} aria-required="true" autoComplete="email" /> {error && ( <div id={errorId} role="alert" aria-live="polite" className="error-message" > {error} </div> )} </div> {success && ( <div id={successId} role="status" aria-live="polite" className="success-message" > {success} </div> )} <button type="submit">Submit</button> </form> ) }
Accessible Tabs
// AccessibleTabs.tsx import { useState, useRef, useEffect, KeyboardEvent } from 'react' interface Tab { id: string label: string content: React.ReactNode } interface AccessibleTabsProps { tabs: Tab[] defaultTab?: string } export function AccessibleTabs({ tabs, defaultTab }: AccessibleTabsProps) { const [activeTab, setActiveTab] = useState(defaultTab || tabs[0].id) const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map()) const handleKeyDown = (e: KeyboardEvent, currentIndex: number) => { let newIndex = currentIndex switch (e.key) { case 'ArrowLeft': newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1 break case 'ArrowRight': newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0 break case 'Home': newIndex = 0 break case 'End': newIndex = tabs.length - 1 break default: return } e.preventDefault() const newTab = tabs[newIndex] setActiveTab(newTab.id) tabRefs.current.get(newTab.id)?.focus() } return ( <div className="tabs"> {/* Tab List */} <div role="tablist" aria-label="Content tabs"> {tabs.map((tab, index) => { const isActive = activeTab === tab.id return ( <button key={tab.id} ref={(el) => { if (el) tabRefs.current.set(tab.id, el) }} role="tab" id={`tab-${tab.id}`} aria-selected={isActive} aria-controls={`panel-${tab.id}`} tabIndex={isActive ? 0 : -1} onClick={() => setActiveTab(tab.id)} onKeyDown={(e) => handleKeyDown(e, index)} className={isActive ? 'active' : ''} > {tab.label} </button> ) })} </div> {/* Tab Panels */} {tabs.map((tab) => ( <div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} hidden={activeTab !== tab.id} tabIndex={0} > {tab.content} </div> ))} </div> ) }
Screen Reader Only Text
/* sr-only.css */ .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; } .sr-only-focusable:focus, .sr-only-focusable:active { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; }
// Usage <button> <TrashIcon aria-hidden="true" /> <span className="sr-only">Delete item</span> </button>
Skip Links
// SkipLinks.tsx export function SkipLinks() { return ( <div className="skip-links"> <a href="#main-content" className="skip-link"> Skip to main content </a> <a href="#navigation" className="skip-link"> Skip to navigation </a> <a href="#footer" className="skip-link"> Skip to footer </a> </div> ) }
/* Skip link styles */ .skip-link { position: absolute; top: -40px; left: 0; background: #000; color: #fff; padding: 8px; text-decoration: none; z-index: 100; } .skip-link:focus { top: 0; }
Automated Testing
Jest + jest-axe
// Button.test.tsx import { render } from '@testing-library/react' import { axe, toHaveNoViolations } from 'jest-axe' import { Button } from './Button' expect.extend(toHaveNoViolations) describe('Button Accessibility', () => { it('should not have accessibility violations', async () => { const { container } = render(<Button>Click me</Button>) const results = await axe(container) expect(results).toHaveNoViolations() }) it('should have accessible name', () => { const { getByRole } = render(<Button>Click me</Button>) expect(getByRole('button', { name: 'Click me' })).toBeInTheDocument() }) it('icon-only button should have aria-label', async () => { const { container } = render( <Button aria-label="Close"> <XIcon /> </Button> ) const results = await axe(container) expect(results).toHaveNoViolations() }) it('disabled button should have aria-disabled', () => { const { getByRole } = render(<Button disabled>Click me</Button>) expect(getByRole('button')).toHaveAttribute('aria-disabled', 'true') }) })
Testing Library Accessibility Queries
// Form.test.tsx import { render, screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' describe('Form Accessibility', () => { it('should have accessible form fields', () => { render(<LoginForm />) // Use accessible queries (getByRole, getByLabelText) const emailInput = screen.getByLabelText(/email/i) const passwordInput = screen.getByLabelText(/password/i) const submitButton = screen.getByRole('button', { name: /log in/i }) expect(emailInput).toBeInTheDocument() expect(passwordInput).toBeInTheDocument() expect(submitButton).toBeInTheDocument() }) it('should show validation errors with proper ARIA', async () => { const user = userEvent.setup() render(<LoginForm />) const submitButton = screen.getByRole('button', { name: /log in/i }) await user.click(submitButton) // Error should be announced to screen readers const errorAlert = screen.getByRole('alert') expect(errorAlert).toHaveTextContent(/email is required/i) }) })
Lighthouse CI
# .lighthouserc.yml ci: collect: numberOfRuns: 3 startServerCommand: 'npm run start' url: - 'http://localhost:3000' assert: preset: 'lighthouse:recommended' assertions: # Accessibility score must be >= 90 'categories:accessibility': - error - minScore: 0.9 # Specific accessibility checks 'aria-allowed-attr': 'error' 'aria-required-attr': 'error' 'aria-valid-attr': 'error' 'button-name': 'error' 'color-contrast': 'error' 'document-title': 'error' 'html-has-lang': 'error' 'image-alt': 'error' 'label': 'error' 'link-name': 'error'
Best Practices
Semantic HTML First
// ❌ BAD - Div soup <div onClick={handleClick}>Click me</div> // ✅ GOOD - Use button <button onClick={handleClick}>Click me</button>
ARIA is a Last Resort
// ❌ BAD - Unnecessary ARIA <button role="button" aria-label="Submit">Submit</button> // ✅ GOOD - Native semantics <button>Submit</button>
Always Provide Text Alternatives
// ❌ BAD - Icon without label <button><XIcon /></button> // ✅ GOOD - Icon with label <button aria-label="Close"><XIcon aria-hidden="true" /></button>
Keyboard Accessibility
// ✅ Ensure all interactive elements are keyboard accessible <div role="button" tabIndex={0} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleClick() } }} > Custom button </div>
Focus Management
// ✅ Manage focus in SPAs useEffect(() => { // Focus heading when page changes headingRef.current?.focus() }, [page])
When to Use This Skill
Activate this skill when you need to:
- Build WCAG compliant components
- Add ARIA attributes correctly
- Implement keyboard navigation
- Create accessible modals/dialogs
- Fix accessibility issues
- Set up automated a11y testing
- Audit accessibility compliance
- Train team on accessibility
- Create accessible forms
- Implement screen reader support
Integration with Agents
// Agent Explore → Scan all components for a11y issues // a11y-specialist → Apply fixes automatically // Example workflow: 1. Agent finds: 50 buttons without accessible names 2. a11y-specialist adds aria-label to all 3. Agent verifies: All buttons now accessible
Output Format
When implementing accessibility, provide:
- Accessible Component: WCAG compliant code
- ARIA Documentation: Explain ARIA usage
- Keyboard Support: Document keyboard interactions
- Test Suite: jest-axe tests included
- Manual Testing Guide: How to test with screen readers
- Compliance Notes: WCAG level achieved
Always build interfaces that are usable by everyone, regardless of ability.