
Accessibility
- 96 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
accessibility is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- accessibility
- AI & Agent Building
- AI-coding skill
Accessibility by the numbers
- 96 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,531 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 96 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Web Accessibility (WCAG 2.2 AA)
Build for everyone — accessibility is a requirement, not a feature.
Contrast Ratios (WCAG AA)
| Element | Minimum Ratio |
|---|---|
| Normal text (< 18pt) | 4.5:1 |
| Large text (>= 18pt or 14pt bold) | 3:1 |
| UI components and focus indicators | 3:1 |
WCAG 2.2 New AA Criteria
| Criterion | Requirement |
|---|---|
| Target Size Minimum (2.5.8) | Interactive targets at least 24x24 CSS pixels |
| Focus Not Obscured Minimum (2.4.11) | Focused element at least partially visible, not hidden by sticky headers or overlays |
| Focus Appearance (2.4.13) | Focus indicator has minimum area (2px perimeter) and 3:1 contrast change |
| Dragging Movements (2.5.7) | Provide single-pointer alternative for any drag interaction |
| Redundant Entry (3.3.7) | Do not require re-entering previously provided information |
| Consistent Help (3.2.6) | Help mechanisms (chat, phone, FAQ) appear in same relative order across pages |
| Accessible Authentication (3.3.8) | No cognitive function test for login (allow paste, autofill, or alternatives) |
Essential Keyboard Patterns
| Key | Action |
|---|---|
| Tab / Shift+Tab | Navigate between focusable elements |
| Enter / Space | Activate buttons and links |
| Arrow keys | Navigate within widgets (tabs, menus) |
| Escape | Close dialogs and menus |
| Home / End | Jump to first/last item in widget |
Element Selection
| Need | Element |
|---|---|
| Navigates to page | <a href="..."> |
| Submits form | <button type="submit"> |
| Opens dialog | <button aria-haspopup="dialog"> |
| Other action | <button type="button"> |
| Self-contained article | <article> |
| Navigation links | <nav> |
| Supplementary info | <aside> |
Common ARIA Attributes
| Attribute | Purpose |
|---|---|
aria-label | Name when no visible label exists |
aria-labelledby | Reference existing text as label |
aria-describedby | Additional description (hints, errors) |
aria-live | Announce dynamic updates (polite or assertive) |
aria-expanded | Collapsible/expandable state |
aria-hidden="true" | Hide decorative elements from screen readers |
aria-invalid | Mark form fields with errors |
aria-required="true" | Mark required fields |
aria-busy | Indicate loading state |
WCAG 2.2 AA Checklist Summary
| Principle | Key Requirements |
|---|---|
| Perceivable | Alt text on images, contrast >= 4.5:1, color not sole indicator, text resizable to 200%, captions on video, prefers-reduced-motion support |
| Operable | Keyboard accessible, visible focus not obscured, focus appearance meets minimum, targets >= 24px, skip links, dragging alternatives |
| Understandable | <html lang="en">, consistent navigation, consistent help placement, form labels, error identification, no redundant entry, accessible auth |
| Robust | Valid HTML, name/role/value on all UI components, aria-live for status messages (SC 4.1.1 Parsing is obsolete in WCAG 2.2) |
Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
<div onClick> instead of <button> | Use semantic HTML elements |
outline: none without replacement | Use :focus-visible with visible outline |
| Placeholder as label | Use <label> element |
tabindex > 0 | Use DOM order or tabindex="0" / tabindex="-1" |
| Color-only state indicators | Add icon and text label |
| Skipped heading levels | Use sequential h1-h6, style with CSS |
role="button" on <button> | Remove redundant ARIA |
aria-hidden on interactive elements | Never hide interactive content |
| Fixed font sizes (px) | Use rem units |
| No focus trap in modal dialogs | Trap focus, close on Escape |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Adding aria-label to elements that already have visible text | Use aria-labelledby to reference existing visible text instead |
Using aria-live="assertive" for non-urgent updates | Use aria-live="polite" for most dynamic content; reserve assertive for errors and alerts |
Setting alt="" on informative images | Provide descriptive alt text; only use empty alt on purely decorative images |
| Adding keyboard handlers to non-focusable elements | Use native interactive elements or add tabindex="0" plus role and key handlers |
| Testing only with automated tools like axe | Automated scans catch ~30% of issues; always supplement with keyboard-only and screen reader testing |
Screen Reader and Browser Pairings
| Screen Reader | Browser | Platform |
|---|---|---|
| JAWS | Chrome | Windows |
| NVDA | Chrome | Windows (free) |
| NVDA | Firefox | Windows (free) |
| VoiceOver | Safari | macOS / iOS |
| TalkBack | Chrome | Android |
| Narrator | Edge | Windows (built-in) |
Testing Quick Guide
| Method | Tool | Effort |
|---|---|---|
| Keyboard-only | Hide mouse, Tab through page | 5 min |
| Screen reader | JAWS + Chrome or NVDA + Chrome/Firefox | 10 min |
| Screen reader | VoiceOver + Safari (macOS) | 10 min |
| Automated scan | axe DevTools browser extension | 2 min |
| Lighthouse | Chrome F12 > Lighthouse > Accessibility | 2 min |
| Unit tests | jest-axe (Jest) or vitest-axe (Vitest) | Ongoing |
Delegation
When working on accessibility, delegate to:
design-system— Color tokens and contrast verificationreact-patterns— Component patterns and hookstesting— jest-axe integration and test patterns
Resources
References
- Semantic HTML and Structure — Document landmarks, heading hierarchy, element selection, skip links
- Focus Management — Focus indicators, focus not obscured, dialog focus traps, SPA route focus, focus-visible patterns
- ARIA Patterns — Accessible tabs, live regions, data tables with ARIA attributes
- Forms and Validation — Label association, error announcement, redundant entry, accessible authentication
- Color and Media — Contrast requirements, reduced motion, dragging alternatives, video captions, alt text
- Testing — Keyboard testing, screen reader pairings, axe DevTools, jest-axe and vitest-axe unit tests
ARIA Patterns
Accessible Tabs
function Tabs({
tabs,
}: {
tabs: Array<{ label: string; content: React.ReactNode }>;
}) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveIndex(index === 0 ? tabs.length - 1 : index - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveIndex(index === tabs.length - 1 ? 0 : index + 1);
} else if (e.key === 'Home') {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setActiveIndex(tabs.length - 1);
}
};
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeIndex === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeIndex !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Arrow keys navigate between tabs. Only the active tab is in the tab order (tabIndex={0}).
ARIA Live Regions
<!-- Polite: waits for screen reader to finish current announcement -->
<div aria-live="polite">New messages: 3</div>
<!-- Assertive: interrupts immediately (use sparingly) -->
<div aria-live="assertive" role="alert">Error: Form submission failed</div>Use aria-atomic="true" to read the entire region on change. Use polite for non-critical updates, assertive for errors and critical alerts.
Accessible Data Tables
<table>
<caption>
Monthly sales by region
</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$10,000</td>
<td>$12,000</td>
</tr>
</tbody>
</table>Use <caption> to describe the table, scope="col" and scope="row" to associate headers with data cells.
Color and Media
Color and Contrast
/* WRONG: insufficient contrast */
:root {
--text: #999999; /* 2.8:1 on white -- fails */
}
/* CORRECT: sufficient contrast */
:root {
--text: #595959; /* 4.6:1 on white -- passes */
}Never use color alone to convey state. Combine color with icons and text labels:
// WRONG: color only
<span style={{ color: 'red' }}>Error</span>
// CORRECT: color + icon + text
<span style={{ color: 'red' }}>
<ErrorIcon aria-hidden="true" /> Error
</span>Text Alternatives
<!-- Informative images: describe content -->
<img src="chart.png" alt="Sales increased 50% in Q4" />
<!-- Decorative images: empty alt -->
<img src="border.png" alt="" />
<!-- Icon buttons: aria-label -->
<button aria-label="Close dialog"><svg>...</svg></button>For complex images, use <figure> with <figcaption> containing a <details> element for long descriptions.
Video and Audio
<video controls>
<source src="video.mp4" type="video/mp4" />
<track
kind="captions"
src="captions.vtt"
srclang="en"
label="English"
default
/>
</video>Require user interaction to start media.
Reduced Motion
Respect the user's system motion preferences. Animations that are purely decorative should be disabled. Essential motion (progress indicators, transitions that convey meaning) can be simplified rather than removed entirely.
@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;
}
}Dragging Alternatives (WCAG 2.5.7)
Any interaction that requires dragging must also work with a single pointer without dragging. Examples: drag-and-drop reordering needs up/down buttons, custom sliders need direct value input, carousels need prev/next buttons.
<!-- Sortable list with both drag and button alternatives -->
<li draggable="true">
<span>Item 1</span>
<button aria-label="Move Item 1 up">Up</button>
<button aria-label="Move Item 1 down">Down</button>
</li>Focus Management
Focus Indicators
/* WRONG: removes focus outline */
button:focus {
outline: none;
}
/* CORRECT: custom accessible outline */
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}Never remove focus outlines without replacement. Use :focus-visible to show outlines only for keyboard users. Ensure 3:1 contrast ratio for focus indicators.
Focus Appearance (WCAG 2.4.13)
The focus indicator must meet minimum size and contrast requirements:
- Minimum area: At least as large as a 2px thick perimeter of the unfocused component
- Contrast change: At least 3:1 contrast ratio between the focused and unfocused states
- Not fully obscured: The indicator must not be entirely hidden by author-created content
/* Meets Focus Appearance minimum */
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Also valid: thick ring with contrast */
a:focus-visible {
box-shadow: 0 0 0 3px var(--primary);
border-radius: 2px;
}Thin 1px outlines or outlines with insufficient contrast against the background may fail this criterion.
Focus Not Obscured (WCAG 2.4.11)
When an element receives keyboard focus, it must not be entirely hidden by sticky headers, footers, cookie banners, or other author-created overlays. At least a portion of the focused element must remain visible.
/* Account for sticky header when focusing elements */
:target {
scroll-margin-top: 80px;
}
/* Ensure focused elements are not hidden under sticky elements */
*:focus-visible {
scroll-margin-top: 80px;
scroll-margin-bottom: 40px;
}Common violations: sticky navigation bars, fixed cookie consent banners, chat widgets overlapping focused content.
Dialog with Focus Trap
function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const previousFocus = document.activeElement as HTMLElement;
const firstFocusable = dialogRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as HTMLElement;
firstFocusable?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (!focusableElements?.length) return;
const first = focusableElements[0] as HTMLElement;
const last = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocus?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
<div className="dialog-backdrop" onClick={onClose} aria-hidden="true" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
>
<h2 id="dialog-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close dialog">
x
</button>
</div>
</>
);
}Key requirements: trap focus inside, restore focus on close, close on Escape.
SPA Focus Management
SPAs do not reset focus on navigation. Handle it explicitly:
function App() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
mainRef.current?.focus();
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.textContent = `Navigated to ${document.title}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}, [location.pathname]);
return (
<main ref={mainRef} tabIndex={-1} id="main-content">
...
</main>
);
}Forms and Validation
Accessible Form Input
function EmailInput({ error }: { error?: string }) {
return (
<>
<label htmlFor="email">Email address *</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
</>
);
}Form Rules
- Every input needs a visible
<label>(placeholders are not labels) - Use
aria-invalidandaria-describedbyfor errors - Use
role="alert"so screen readers announce error messages - Mark required fields with
aria-required="true"and visual indicator - Provide clear instructions before complex forms
- Identify errors in text, not just color
Redundant Entry (WCAG 3.3.7)
Do not require users to re-enter information they have already provided in the same process. Auto-populate from earlier steps or offer a selection from previously entered data.
// Multi-step form: carry forward previous answers
function ShippingStep({ billingAddress }: { billingAddress: Address }) {
const [useSameAddress, setUseSameAddress] = useState(true);
return (
<fieldset>
<legend>Shipping Address</legend>
<label>
<input
type="checkbox"
checked={useSameAddress}
onChange={(e) => setUseSameAddress(e.target.checked)}
/>
Same as billing address
</label>
{useSameAddress ? null : <AddressForm />}
</fieldset>
);
}Consistent Help (WCAG 3.2.6)
If a website provides help mechanisms (human contact details, automated chat, self-help links, FAQ), those mechanisms must appear in the same relative order on each page. The help does not need to be on every page, but when present, it must be consistently placed.
<!-- Footer help section: same order on every page -->
<footer>
<nav aria-label="Help">
<a href="/faq">FAQ</a>
<a href="/contact">Contact Us</a>
<a href="/chat">Live Chat</a>
</nav>
</footer>Accessible Authentication (WCAG 3.3.8)
Login flows must not require cognitive function tests (like remembering a password from memory without paste). Allow password managers (do not block paste), support autofill with autocomplete attributes, and provide alternatives to CAPTCHAs.
<input type="password" id="password" autocomplete="current-password" />
<!-- Never set autocomplete="off" on password fields -->
<!-- Never block paste events on password fields -->Semantic HTML and Structure
Use the right element. Semantic elements provide built-in keyboard support and screen reader announcements.
Semantic vs Non-Semantic
<!-- WRONG: divs with onClick -->
<div onclick="submit()">Submit</div>
<div onclick="navigate()">Next page</div>
<!-- CORRECT: semantic elements -->
<button type="submit">Submit</button>
<a href="/next">Next page</a>Document Structure and Landmarks
<header>
<nav aria-label="Main navigation">...</nav>
</header>
<main id="main-content">
<h1>Page Title</h1>
<h2>Section 1</h2>
<h3>Subsection 1.1</h3>
<h2>Section 2</h2>
<article>...</article>
<aside>...</aside>
</main>
<footer>...</footer>Heading hierarchy must not skip levels (h1 then h3 with no h2).
ARIA: Only When HTML Cannot Express the Pattern
<!-- WRONG: unnecessary ARIA -->
<button role="button">Click me</button>
<!-- CORRECT: ARIA fills a semantic gap -->
<div role="dialog" aria-labelledby="title" aria-modal="true">
<h2 id="title">Confirm action</h2>
</div>
<!-- BETTER: native HTML when available -->
<dialog aria-labelledby="title">
<h2 id="title">Confirm action</h2>
</dialog>Skip Links
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content" tabindex="-1">...</main>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--primary);
color: white;
padding: 8px 16px;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}Testing
Keyboard-Only Testing (5 minutes)
1. Hide or unplug mouse 2. Tab through entire page — can you reach all interactive elements? 3. Enter/Space to activate buttons and links 4. Escape to close dialogs and menus 5. Arrow keys within tabs, menus, radio groups 6. Verify focus order is logical
Screen Reader Testing (10 minutes)
Recommended pairings (by usage):
| Priority | Screen Reader | Browser | Platform |
|---|---|---|---|
| 1 | JAWS | Chrome | Windows |
| 2 | NVDA | Chrome | Windows (free) |
| 3 | VoiceOver | Safari | macOS / iOS |
| 4 | NVDA | Firefox | Windows (free) |
| 5 | TalkBack | Chrome | Android |
VoiceOver (Mac, built-in): Cmd+F5 to start, VO+Right/Left to navigate (VO = Ctrl+Option), VO+A to read all.
NVDA (Windows, free): Ctrl+Alt+N to start, arrow keys or Tab to navigate, NVDA+Down to read.
JAWS (Windows, paid): Insert+Down to start reading, Tab to navigate interactive elements, Insert+F7 to list links.
Screen reader shortcuts: H/Shift+H to navigate by heading, D/Shift+D to navigate by landmark.
Verify: interactive elements announced correctly, images described, form labels read with inputs, dynamic updates announced, heading structure navigable.
Automated Testing
axe DevTools (browser extension): F12 then axe DevTools tab then Scan.
Lighthouse (Chrome built-in): F12 then Lighthouse tab, select Accessibility, generate report. Target score 90+.
Unit Tests with axe
Use jest-axe for Jest or vitest-axe for Vitest. Both wrap axe-core with the same API.
// Jest: npm install --save-dev jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
// Vitest: npm install --save-dev vitest-axe
// import { axe, toHaveNoViolations } from 'vitest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Color contrast checks do not work in JSDOM and are disabled by default. Use axe DevTools or Lighthouse for contrast auditing.
For browser-based or E2E testing, use @axe-core/playwright or cypress-axe instead.
Troubleshooting
Focus indicators not visible
Cause: CSS reset removed outlines or insufficient contrast on indicator. Fix: Add *:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
Screen reader not announcing updates
Cause: No aria-live region. Fix: Wrap dynamic content in <div aria-live="polite"> or use role="alert" for errors.
Dialog focus escapes to background
Cause: No focus trap implemented. Fix: Trap focus within dialog, close on Escape, restore focus on close.
Form errors not announced
Cause: Missing aria-invalid or role="alert". Fix: Set aria-invalid="true" on input, point aria-describedby to error element with role="alert".
Keyboard cannot reach interactive element
Cause: Using <div> with onClick instead of <button>. Fix: Use semantic HTML. If custom element unavoidable, add role="button", tabIndex={0}, and onKeyDown for Enter/Space.
Heading hierarchy broken
Cause: Headings chosen for visual size instead of semantic level. Fix: Use h1-h6 in order. Style with CSS classes, not heading levels.