Help us improve
Share bugs, ideas, or general feedback.
From clarc
Provides WCAG 2.2 guidelines, ARIA patterns, keyboard navigation, focus management, screen reader testing, and axe-core automated tests for accessible web UIs like modals, forms, and tables.
npx claudepluginhub marvinrichter/clarc --plugin clarcHow this skill is triggered — by the user, by Claude, or both
Slash command
/clarc:accessibility-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build accessible UIs that work for all users — keyboard, screen readers, and beyond.
Implements WCAG 2.1/2.2 compliance, ARIA patterns, keyboard navigation, focus management, and accessibility testing for web components.
Provides WCAG 2.2 AA accessibility patterns for keyboard focus management, React Aria components, screen reader support, ARIA usage, focus traps, reduced motion, and cognitive inclusion. Use for compliant web apps.
Audits and implements frontend accessibility for WCAG 2.2 AA compliance, ARIA roles/attributes, keyboard navigation, focus management, screen readers, and automated testing.
Share bugs, ideas, or general feedback.
Build accessible UIs that work for all users — keyboard, screen readers, and beyond.
| Criterion | Level | What it means |
|---|---|---|
| 1.1.1 Non-text Content | A | All images need alt text or aria-label |
| 1.3.1 Info and Relationships | A | Use semantic HTML — headings, lists, landmarks |
| 1.4.3 Contrast Minimum | AA | Text: 4.5:1 ratio; large text (≥18pt): 3:1 |
| 1.4.11 Non-text Contrast | AA | UI components and graphics: 3:1 ratio |
| 2.1.1 Keyboard | A | Everything operable via keyboard |
| 2.4.3 Focus Order | A | Tab order is logical and meaningful |
| 2.4.7 Focus Visible | AA | Keyboard focus is always visible |
| 2.4.11 Focus Appearance | AA | (WCAG 2.2 new) Focus indicator: ≥2px, ≥3:1 contrast |
| 2.5.8 Target Size | AA | (WCAG 2.2 new) Touch targets ≥24×24 CSS px |
| 4.1.2 Name, Role, Value | A | ARIA attributes are correct and complete |
<!-- aria-label: inline label, no visible text -->
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- aria-labelledby: reference to visible element -->
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
...
</div>
<!-- aria-describedby: supplementary description -->
<input
id="email"
type="email"
aria-describedby="email-hint email-error"
/>
<p id="email-hint">Use your company email address.</p>
<p id="email-error" role="alert">This field is required.</p>
<!-- Prefer aria-labelledby over aria-label when visible text exists -->
<!-- Prefer semantic HTML over ARIA where possible -->
<!-- Only add role when semantic HTML is not available -->
<div role="button" tabindex="0" onkeydown="handleKey(event)">Custom Button</div>
<!-- Better: use <button> instead -->
<!-- Landmark roles (use semantic HTML equivalents) -->
<div role="banner">...</div> <!-- Use <header> -->
<div role="main">...</div> <!-- Use <main> -->
<div role="navigation">...</div> <!-- Use <nav> -->
<div role="contentinfo">...</div> <!-- Use <footer> -->
<div role="complementary">...</div> <!-- Use <aside> -->
<!-- Widget roles — no semantic equivalent -->
<div role="dialog" aria-modal="true" aria-labelledby="title">
<div role="tooltip" id="tip-1">
<div role="tabpanel" aria-labelledby="tab-1">
<div role="status" aria-live="polite"> <!-- for status updates -->
<!-- polite: announces after current user action completes -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<!-- Dynamic content here — screen reader announces on change -->
3 results found
</div>
<!-- assertive: interrupts immediately — use sparingly for errors -->
<div role="alert" aria-live="assertive">
Error: Payment failed. Please try again.
</div>
<!-- status: polite, lower priority -->
<div role="status">Saving...</div>
<!-- Expandable controls -->
<button aria-expanded="false" aria-controls="menu-list">Menu</button>
<ul id="menu-list" hidden>...</ul>
<!-- Toggle buttons -->
<button aria-pressed="false">Bold</button>
<!-- Selectable items (tabs, list items) -->
<div role="tab" aria-selected="true" id="tab-1">Details</div>
<!-- Form state -->
<input aria-required="true" aria-invalid="true" aria-describedby="error-1" />
<p id="error-1" role="alert">Email address is required.</p>
<!-- Busy/loading state -->
<div aria-busy="true" aria-label="Loading results...">
<Spinner />
</div>
// Focus trap — keep focus inside modal while open
function trapFocus(container: HTMLElement): () => void {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const focusableElements = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelectors)
);
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1)!;
// Move focus into modal on open
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}
// React hook
function useFocusTrap(isOpen: boolean, containerRef: RefObject<HTMLElement>) {
useEffect(() => {
if (!isOpen || !containerRef.current) return;
const previouslyFocused = document.activeElement as HTMLElement;
const cleanup = trapFocus(containerRef.current);
return () => {
cleanup();
// Restore focus when modal closes
previouslyFocused?.focus();
};
}, [isOpen]);
}
<!-- First element on page — skip to main content -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" allows programmatic focus without appearing in tab order -->
...
</main>
/* Visible only when focused */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
z-index: 9999;
padding: 8px;
background: #000;
color: #fff;
text-decoration: none;
}
.skip-link:focus {
top: 6px;
}
// Restore focus to page heading on every route change
function RouterFocusManager() {
const pathname = usePathname();
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return <h1 ref={headingRef} tabIndex={-1}>{getPageTitle(pathname)}</h1>;
}
// Full keyboard support: Enter/Space/ArrowDown opens, ArrowUp/Down/Home/End navigate, Escape closes
function DropdownMenu({ trigger, items }: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const handleKeyDown = (e: KeyboardEvent) => {
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActiveIndex(0);
}
return;
}
switch (e.key) {
case 'ArrowDown': e.preventDefault(); setActiveIndex(i => Math.min(i + 1, items.length - 1)); break;
case 'ArrowUp': e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); break;
case 'Home': e.preventDefault(); setActiveIndex(0); break;
case 'End': e.preventDefault(); setActiveIndex(items.length - 1); break;
case 'Escape': setOpen(false); break;
case 'Tab': setOpen(false); break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)}>
Options
</button>
{open && (
<ul role="menu">
{items.map((item, i) => (
<li key={item.id} role="menuitem" tabIndex={activeIndex === i ? 0 : -1}
onClick={() => { item.onSelect(); setOpen(false); }}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// Only one item in group has tabindex="0" at a time
// Arrow keys move focus within the group
function RovingTabGroup({ items }: { items: string[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let next = index;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (index + 1) % items.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (index - 1 + items.length) % items.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = items.length - 1;
} else {
return;
}
e.preventDefault();
setActiveIndex(next);
itemRefs[next]?.focus();
};
return (
<div role="radiogroup">
{items.map((item, i) => (
<div
key={item}
role="radio"
tabIndex={i === activeIndex ? 0 : -1}
aria-checked={i === activeIndex}
onKeyDown={(e) => handleKeyDown(e, i)}
ref={(el) => { itemRefs[i] = el; }}
>
{item}
</div>
))}
</div>
);
}
// All overlays (modals, drawers, dropdowns, tooltips) must close on Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
For screen reader testing checklists (NVDA/VoiceOver), automated testing (axe-core, jest-axe, @axe-core/react), and accessible component patterns (modal, table, form, data visualization), see skill accessibility-patterns-advanced.