Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Web accessibility compliance specialist. Use when conducting WCAG compliance audits, testing screen reader compatibility, validating keyboard navigation, or ensuring inclusive design. Focuses on WCAG 2.1/2.2 standards.
.claude/skills/aiskillstore-web-accessibility/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 263% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 194% | 0% |
| case-03 | ✓→✗ | ▼ Worse | 190% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 147% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 114% | 0% |
Make a React modal component accessible:
- Framework: React + TypeScript
- WCAG Level: AA
- Requirements:
- Focus trap (focus stays inside the modal)
- Close with ESC key
- Close by clicking the background
- Title/description read by screen readersUse meaningful HTML elements to make the structure clear.
Tasks:
<button>, <nav>, <main>, <header>, <footer>, etc.<div> and <span><h1> ~ <h6>) correctly<label> with <input>Example (❌ Bad vs ✅ Good):
html<!-- ❌ Bad example: using only div and span --> <div class="header"> <span class="title">My App</span> <div class="nav"> <div class="nav-item" onclick="navigate()">Home</div> <div class="nav-item" onclick="navigate()">About</div> </div> </div> <!-- ✅ Good example: semantic HTML --> <header> <h1>My App</h1> <nav aria-label="Main navigation"> <ul> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> </ul> </nav> </header>
Form Example:
html<!-- ❌ Bad example: no label --> <input type="text" placeholder="Enter your name"> <!-- ✅ Good example: label connected --> <label for="name">Name:</label> <input type="text" id="name" name="name" required> <!-- Or wrap with label --> <label> Email: <input type="email" name="email" required> </label>
Ensure all features are usable without a mouse.
Tasks:
tabindex appropriatelyDecision Criteria:
tabindex="0" (focusable)tabindex="-1" (programmatic focus only)tabindex="1+"Example (React Dropdown):
typescriptimport React, { useState, useRef, useEffect } from 'react'; interface DropdownProps { label: string; options: { value: string; label: string }[]; onChange: (value: string) => void; } function AccessibleDropdown({ label, options, onChange }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const buttonRef = useRef<HTMLButtonElement>(null); const listRef = useRef<HTMLUListElement>(null); // Keyboard handler const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { setSelectedIndex((prev) => (prev + 1) % options.length); } break; case 'ArrowUp': e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { setSelectedIndex((prev) => (prev - 1 + options.length) % options.length); } break; case 'Enter': case ' ': e.preventDefault(); if (isOpen) { onChange(options[selectedIndex].value); setIsOpen(false); buttonRef.current?.focus(); } else { setIsOpen(true); } break; case 'Escape': e.preventDefault(); setIsOpen(false); buttonRef.current?.focus(); break; } }; return ( <div className="dropdown"> <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)} onKeyDown={handleKeyDown} aria-haspopup="listbox" aria-expanded={isOpen} aria-labelledby="dropdown-label" > {label} </button> {isOpen && ( <ul ref={listRef} role="listbox" aria-labelledby="dropdown-label" onKeyDown={handleKeyDown} tabIndex={-1} > {options.map((option, index) => ( <li key={option.value} role="option" aria-selected={index === selectedIndex} onClick={() => { onChange(option.value); setIsOpen(false); }} > {option.label} </li> ))} </ul> )} </div> ); }
Provide additional context for screen readers.
Tasks:
aria-label: Define the element's namearia-labelledby: Reference another element as a labelaria-describedby: Provide additional descriptionaria-live: Announce dynamic content changesaria-hidden: Hide from screen readersChecklist:
Example (Modal):
tsxfunction AccessibleModal({ isOpen, onClose, title, children }) { const modalRef = useRef<HTMLDivElement>(null); // Focus trap when modal opens useEffect(() => { if (isOpen) { modalRef.current?.focus(); } }, [isOpen]); if (!isOpen) return null; return ( <div role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-description" ref={modalRef} tabIndex={-1} onKeyDown={(e) => { if (e.key === 'Escape') { onClose(); } }} > <div className="modal-overlay" onClick={onClose} aria-hidden="true" /> <div className="modal-content"> <h2 id="modal-title">{title}</h2> <div id="modal-description"> {children} </div> <button onClick={onClose} aria-label="Close modal"> <span aria-hidden="true">×</span> </button> </div> </div> ); }
aria-live Example (Notifications):
tsxfunction Notification({ message, type }: { message: string; type: 'success' | 'error' }) { return ( <div role="alert" aria-live="assertive" // Immediate announcement (error), "polite" announces in turn aria-atomic="true" // Read the entire content className={`notification notification-${type}`} > {type === 'error' && <span aria-label="Error">⚠️</span>} {type === 'success' && <span aria-label="Success">✅</span>} {message} </div> ); }
Ensure sufficient contrast ratios for users with visual impairments.
Tasks:
Example (CSS):
css/* ✅ Sufficient contrast (text #000 on #FFF = 21:1) */ .button { background-color: #0066cc; color: #ffffff; /* contrast ratio 7.7:1 */ } /* ✅ Focus indicator */ button:focus, a:focus { outline: 3px solid #0066cc; outline-offset: 2px; } /* ❌ outline: none is forbidden! */ button:focus { outline: none; /* Never use this */ } /* ✅ Indicate state with color + icon */ .error-message { color: #d32f2f; border-left: 4px solid #d32f2f; } .error-message::before { content: '⚠️'; margin-right: 8px; }
Validate accessibility with automated and manual testing.
Tasks:
Example (Jest + axe-core):
typescriptimport { render } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; import AccessibleButton from './AccessibleButton'; expect.extend(toHaveNoViolations); describe('AccessibleButton', () => { it('should have no accessibility violations', async () => { const { container } = render( <AccessibleButton onClick={() => {}}> Click Me </AccessibleButton> ); const results = await axe(container); expect(results).toHaveNoViolations(); }); it('should be keyboard accessible', () => { const handleClick = jest.fn(); const { getByRole } = render( <AccessibleButton onClick={handleClick}> Click Me </AccessibleButton> ); const button = getByRole('button'); // Enter key button.focus(); fireEvent.keyDown(button, { key: 'Enter' }); expect(handleClick).toHaveBeenCalled(); // Space key fireEvent.keyDown(button, { key: ' ' }); expect(handleClick).toHaveBeenCalledTimes(2); }); });
markdown## Accessibility Checklist ### Semantic HTML - [x] Use semantic HTML tags (`<button>`, `<nav>`, `<main>`, etc.) - [x] Heading hierarchy is correct (h1 → h2 → h3) - [x] All form labels are connected ### Keyboard Navigation - [x] All interactive elements accessible via Tab - [x] Buttons activated with Enter/Space - [x] Modals/dropdowns closed with ESC - [x] Focus indicator is clear (outline) ### ARIA - [x] `role` used appropriately - [x] `aria-label` or `aria-labelledby` provided - [x] `aria-live` used for dynamic content - [x] Decorative elements use `aria-hidden="true"` ### Visual - [x] Color contrast meets WCAG AA (4.5:1) - [x] Information not conveyed by color alone - [x] Text size can be adjusted - [x] Responsive design ### Testing - [x] 0 axe DevTools violations - [x] Lighthouse Accessibility score 90+ - [x] Keyboard test passed - [x] Screen reader test completed
alt attributealt="" (screen reader ignores)<label for="..."> or aria-labeloutline: nonetsxfunction AccessibleContactForm() { const [errors, setErrors] = useState<Record<string, string>>({}); const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); return ( <form onSubmit={handleSubmit} noValidate> <h2 id="form-title">Contact Us</h2> <p id="form-description">Please fill out the form below to get in touch.</p> {/* Name */} <div className="form-group"> <label htmlFor="name"> Name <span aria-label="required">*</span> </label> <input type="text" id="name" name="name" required aria-required="true" aria-invalid={!!errors.name} aria-describedby={errors.name ? 'name-error' : undefined} /> {errors.name && ( <span id="name-error" role="alert" className="error"> {errors.name} </span> )} </div> {/* Email */} <div className="form-group"> <label htmlFor="email"> Email <span aria-label="required">*</span> </label> <input type="email" id="email" name="email" required aria-required="true" aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : 'email-hint'} /> <span id="email-hint" className="hint"> We'll never share your email. </span> {errors.email && ( <span id="email-error" role="alert" className="error"> {errors.email} </span> )} </div> {/* Submit button */} <button type="submit" disabled={submitStatus === 'loading'}> {submitStatus === 'loading' ? 'Submitting...' : 'Submit'} </button> {/* Success/failure messages */} {submitStatus === 'success' && ( <div role="alert" aria-live="polite" className="success"> ✅ Form submitted successfully! </div> )} {submitStatus === 'error' && ( <div role="alert" aria-live="assertive" className="error"> ⚠️ An error occurred. Please try again. </div> )} </form> ); }
tsxfunction AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) { const [activeTab, setActiveTab] = useState(0); const handleKeyDown = (e: React.KeyboardEvent, index: number) => { switch (e.key) { case 'ArrowRight': e.preventDefault(); setActiveTab((index + 1) % tabs.length); break; case 'ArrowLeft': e.preventDefault(); setActiveTab((index - 1 + tabs.length) % tabs.length); break; case 'Home': e.preventDefault(); setActiveTab(0); break; case 'End': e.preventDefault(); setActiveTab(tabs.length - 1); break; } }; return ( <div> {/* Tab List */} <div role="tablist" aria-label="Content sections"> {tabs.map((tab, index) => ( <button key={tab.id} role="tab" id={`tab-${tab.id}`} aria-selected={activeTab === index} aria-controls={`panel-${tab.id}`} tabIndex={activeTab === index ? 0 : -1} onClick={() => setActiveTab(index)} onKeyDown={(e) => handleKeyDown(e, index)} > {tab.label} </button> ))} </div> {/* Tab Panels */} {tabs.map((tab, index) => ( <div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} hidden={activeTab !== index} tabIndex={0} > {tab.content} </div> ))} </div> ); }
<button> vs <div role="button">#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | pass→pass | 17,213 | 16,257 | -6% | 1 | 1 | 0% | 2,937 | 7,242 | +147% | 0 | 0 | — |
case-01 | fail→fail | 36,201 | 22,783 | -37% | 1 | 1 | 0% | 7,100 | 9,117 | +28% | 0 | 0 | — |
case-02 | fail→fail | 39,731 | 38,728 | -3% | 1 | 1 | 0% | 8,269 | 12,851 | +55% | 0 | 0 | — |
case-03 | pass→fail | 17,905 | 22,746 | +27% | 1 | 1 | 0% | 2,984 | 8,654 | +190% | 0 | 0 | — |
case-04 | pass→pass | 52,800 | 13,849 | -74% | 1 | 1 | 0% | 3,388 | 7,267 | +114% | 0 | 0 | — |
case-06 | fail→fail | 12,548 | 14,160 | +13% | 1 | 1 | 0% | 2,274 | 7,078 | +211% | 0 | 0 | — |
case-07 | pass→pass | 11,751 | 13,571 | +15% | 1 | 1 | 0% | 2,016 | 6,989 | +247% | 0 | 0 | — |
case-08 | pass→pass | 17,261 | 16,940 | -2% | 1 | 1 | 0% | 3,203 | 7,531 | +135% | 0 | 0 | — |
case-09 | pass→pass | 14,340 | 12,289 | -14% | 1 | 1 | 0% | 2,273 | 7,038 | +210% | 0 | 0 | — |
case-10 | fail→pass | 10,092 | 10,870 | +8% | 1 | 1 | 0% | 1,787 | 6,487 | +263% | 0 | 0 | — |
case-11 | pass→pass | 27,570 | 24,608 | -11% | 1 | 1 | 0% | 5,514 | 8,598 | +56% | 0 | 0 | — |
case-12 | pass→pass | 14,183 | 21,488 | +52% | 1 | 1 | 0% | 2,390 | 7,747 | +224% | 0 | 0 | — |
case-13 | pass→pass | 18,320 | 19,314 | +5% | 1 | 1 | 0% | 3,346 | 7,565 | +126% | 0 | 0 | — |
case-14 | pass→pass | 11,115 | 10,864 | -2% | 1 | 1 | 0% | 2,199 | 6,865 | +212% | 0 | 0 | — |
case-15 | fail→pass | 13,858 | 14,483 | +5% | 1 | 1 | 0% | 2,470 | 7,261 | +194% | 0 | 0 | — |
case-16 | pass→pass | 9,331 | 9,784 | +5% | 1 | 1 | 0% | 1,589 | 6,281 | +295% | 0 | 0 | — |
case-22 | pass→pass | 9,348 | 18,655 | +100% | 1 | 1 | 0% | 2,075 | 7,034 | +239% | 0 | 0 | — |
case-17 | pass→pass | 9,203 | 11,465 | +25% | 1 | 1 | 0% | 1,211 | 6,224 | +414% | 0 | 0 | — |
case-18 | fail→fail | 16,203 | 16,380 | +1% | 1 | 1 | 0% | 2,670 | 7,712 | +189% | 0 | 0 | — |
case-19 | pass→pass | 14,598 | 22,029 | +51% | 1 | 1 | 0% | 2,668 | 7,696 | +188% | 0 | 0 | — |
case-20 | pass→pass | 14,660 | 24,183 | +65% | 1 | 1 | 0% | 2,758 | 8,211 | +198% | 0 | 0 | — |
case-21 | pass→pass | 20,220 | 15,102 | -25% | 1 | 1 | 0% | 3,103 | 7,478 | +141% | 0 | 0 | — |
case-23 | pass→pass | 21,117 | 23,365 | +11% | 1 | 1 | 0% | 3,249 | 8,760 | +170% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of 0 percentage points is the difference between those two pass rates over the 23 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 8/22/2026 | +9% |
Other measured skills in the registry, with their headline benchmark lift.