accessibility-wcag
Use this skill when implementing web accessibility, adding ARIA attributes, ensuring keyboard navigation, or auditing WCAG compliance. Triggers on accessibility, a11y, ARIA roles, screen readers, keyboard navigation, focus management, color contrast, alt text, semantic HTML, and any task requiring WCAG 2.2 compliance or inclusive design.
design accessibilitywcagariaa11ykeyboardscreen-readerWhat is accessibility-wcag?
Use this skill when implementing web accessibility, adding ARIA attributes, ensuring keyboard navigation, or auditing WCAG compliance. Triggers on accessibility, a11y, ARIA roles, screen readers, keyboard navigation, focus management, color contrast, alt text, semantic HTML, and any task requiring WCAG 2.2 compliance or inclusive design.
accessibility-wcag
accessibility-wcag is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex. Implementing web accessibility, adding ARIA attributes, ensuring keyboard navigation, or auditing WCAG compliance.
Quick Facts
| Field | Value |
|---|---|
| Category | design |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill accessibility-wcag- The accessibility-wcag skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
A production-grade skill for building inclusive web experiences. It encodes WCAG 2.2 standards, ARIA authoring practices, keyboard interaction patterns, and screen reader testing guidance into actionable rules and working code. Accessibility is not a checkbox - it is the baseline quality bar. Every user deserves a working product, regardless of how they interact with it.
Tags
accessibility wcag aria a11y keyboard screen-reader
Platforms
- claude-code
- gemini-cli
- openai-codex
Related Skills
Pair accessibility-wcag with these complementary skills:
Frequently Asked Questions
What is accessibility-wcag?
Use this skill when implementing web accessibility, adding ARIA attributes, ensuring keyboard navigation, or auditing WCAG compliance. Triggers on accessibility, a11y, ARIA roles, screen readers, keyboard navigation, focus management, color contrast, alt text, semantic HTML, and any task requiring WCAG 2.2 compliance or inclusive design.
How do I install accessibility-wcag?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill accessibility-wcag in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support accessibility-wcag?
This skill works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Accessibility & WCAG
A production-grade skill for building inclusive web experiences. It encodes WCAG 2.2 standards, ARIA authoring practices, keyboard interaction patterns, and screen reader testing guidance into actionable rules and working code. Accessibility is not a checkbox - it is the baseline quality bar. Every user deserves a working product, regardless of how they interact with it.
When to use this skill
Trigger this skill when the user:
- Asks to make a component or page accessible or "a11y compliant"
- Needs to add ARIA roles, states, or properties to custom widgets
- Wants keyboard navigation implemented for interactive components
- Asks about screen reader support, announcements, or live regions
- Needs a WCAG 2.2 audit or compliance review
- Is working on focus management (modals, SPAs, route changes)
- Asks about color contrast, alt text, semantic HTML, or form labeling
- Is building custom widgets (dialog, tabs, combobox, menu, tooltip)
Do NOT trigger this skill for:
- Pure backend code with no HTML output or DOM interaction
- CSS-only styling questions that have no accessibility implications
Key principles
Semantic HTML first - The single highest-leverage accessibility action is using the right HTML element.
<button>gives you keyboard support, focus, activation, and screen reader announcement for free. No ARIA patch matches it.ARIA is a last resort - ARIA fills gaps where native HTML falls short. Before adding an ARIA attribute, ask: "is there a native element that does this?" If yes, use that element instead. Bad ARIA is worse than no ARIA.
Keyboard accessible everything - If a sighted mouse user can do something, a keyboard-only user must be able to do the same thing. There are no exceptions in WCAG 2.1 AA. Test every interaction without a mouse.
Test with real assistive technology - Automated tools catch approximately 30% of WCAG failures. The remaining 70% - focus management correctness, announcement quality, logical reading order, cognitive load - requires manual testing with VoiceOver, NVDA, or real users with disabilities.
Accessibility is not optional - It is a legal requirement (ADA, Section 508, EN 301 549), a quality signal, and the right thing to do. Build it in from the start; retrofitting is ten times harder than doing it correctly the first time.
Core concepts
POUR Principles (WCAG foundation)
Every WCAG criterion maps to one of four properties:
| Principle | Definition | Examples |
|---|---|---|
| Perceivable | Info must be presentable to users in ways they can perceive | Alt text, captions, sufficient contrast, adaptable layout |
| Operable | UI must be operable by all users | Keyboard access, no seizure-triggering content, enough time |
| Understandable | Info and UI must be understandable | Clear labels, consistent navigation, error identification |
| Robust | Content must be robust enough for AT to parse | Valid HTML, ARIA used correctly, name/role/value exposed |
WCAG Conformance Levels
| Level | Meaning | Target |
|---|---|---|
| A | Removes major barriers | Legal floor in most jurisdictions |
| AA | Removes most barriers | Industry standard; required by ADA, EN 301 549, AODA |
| AAA | Enhanced, specialized needs | Aspirational; not required for full sites |
Target AA. New WCAG 2.2 AA criteria: focus appearance (2.4.11), dragging alternative (2.5.7), minimum target size 24x24px (2.5.8).
ARIA Roles, States, and Properties
ARIA exposes semantics to the accessibility tree - it does not change visual rendering or add keyboard behavior. Three categories:
- Roles - What the element is:
role="dialog",role="tab",role="alert" - States - Dynamic condition:
aria-expanded,aria-selected,aria-disabled,aria-invalid - Properties - Stable relationships:
aria-label,aria-labelledby,aria-describedby,aria-controls
The Five Rules of ARIA:
- Don't use ARIA if a native HTML element exists
- Don't change native semantics unless absolutely necessary
- All interactive ARIA controls must be keyboard operable
- Don't apply
aria-hidden="true"to focusable elements - All interactive elements must have an accessible name
Focus Management Model
- Tab order follows DOM order - keep DOM order logical and matching visual order
tabindex="0"- adds element to natural tab ordertabindex="-1"- programmatically focusable but removed from tab sequencetabindex="1+"- avoid; creates unpredictable tab order- Roving tabindex - composite widgets (tabs, toolbars, radio groups): only one item in tab order at a time; arrow keys navigate within
- Focus trap - modal dialogs must trap Tab/Shift+Tab within the dialog
- Focus return - always return focus to the trigger element when a modal or overlay closes
Common tasks
1. Write semantic HTML for common patterns
Choose elements for meaning, not appearance. Native semantics are free accessibility.
<!-- Page structure -->
<header>
<nav aria-label="Primary navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content" tabindex="-1">
<h1>Page Title</h1>
<article>
<h2>Article heading</h2>
<p>Content...</p>
</article>
<aside aria-label="Related links">...</aside>
</main>
<footer>
<nav aria-label="Footer navigation">...</nav>
</footer>
<!-- Skip link - must be first focusable element -->
<a href="#main-content" class="skip-link">Skip to main content</a>.skip-link {
position: absolute;
top: -100%;
left: 0;
background: #005fcc;
color: #fff;
padding: 0.5rem 1rem;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}2. Implement keyboard navigation for custom widgets
Roving tabindex for a toolbar/tab list - only one item in tab order at a time:
function Toolbar({ items }: { items: { id: string; label: string }[] }) {
const [activeIndex, setActiveIndex] = React.useState(0);
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next = index;
if (e.key === 'ArrowRight') next = (index + 1) % items.length;
else if (e.key === 'ArrowLeft') 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);
refs.current[next]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting">
{items.map((item, i) => (
<button
key={item.id}
ref={(el) => { refs.current[i] = el; }}
tabIndex={i === activeIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => setActiveIndex(i)}
>
{item.label}
</button>
))}
</div>
);
}3. Add ARIA to interactive components
For detailed accessible Dialog (Modal) and Tabs implementations with focus trapping, roving tabindex, and correct ARIA roles/states, see references/widget-examples.md.
4. Ensure color contrast compliance
WCAG AA contrast requirements:
| Element | Minimum ratio |
|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 |
| Large text (>= 18pt / >= 14pt bold) | 3:1 |
| UI components (input borders, icons) | 3:1 |
| Focus indicators | 3:1 against adjacent color |
/* Focus ring - must meet 3:1 against neighboring colors */
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
border-radius: 2px;
}
/* Never convey information by color alone */
.field-error {
color: #c0392b; /* red - supplementary only */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* The icon + text label carry the meaning; color is an enhancement */
.field-error::before {
content: '';
display: inline-block;
width: 1em;
height: 1em;
background: url('error-icon.svg') no-repeat center;
}Tools: Chrome DevTools contrast panel, axe DevTools extension, Colour Contrast Analyser (desktop), npx lighthouse --only-categories=accessibility.
5. Manage focus for SPAs and modals
// SPA route change - announce and move focus
function useRouteAccessibility() {
const location = useLocation();
const headingRef = React.useRef<HTMLHeadingElement>(null);
React.useEffect(() => {
// Update document title
document.title = `${getPageTitle(location.pathname)} - My App`;
// Move focus to h1 so keyboard users know where they are
headingRef.current?.focus();
// Optional: announce via live region
const announcer = document.getElementById('route-announcer');
if (announcer) announcer.textContent = `Navigated to ${getPageTitle(location.pathname)}`;
}, [location.pathname]);
return headingRef;
}
// In your page component:
function Page({ title }: { title: string }) {
const headingRef = useRouteAccessibility();
return (
<>
{/* Persistent live region - created once, reused */}
<div id="route-announcer" aria-live="polite" aria-atomic="true"
className="sr-only" />
<h1 tabIndex={-1} ref={headingRef}>{title}</h1>
</>
);
}/* Visually hidden but available to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}6. Write effective alt text and labels
<!-- Informative image: describe purpose, not appearance -->
<img src="revenue-chart.png"
alt="Q4 revenue: grew from $2M in October to $3.5M in December">
<!-- Decorative image: empty alt, screen reader skips it -->
<img src="decorative-wave.svg" alt="">
<!-- Functional image (inside link or button): describe the action -->
<a href="/home"><img src="logo.svg" alt="Acme Corp - Go to homepage"></a>
<button><img src="search-icon.svg" alt="Search"></button>
<!-- Complex image: short alt + long description -->
<figure>
<img src="architecture-diagram.png"
alt="System architecture overview"
aria-describedby="arch-desc">
<figcaption id="arch-desc">
The frontend (React) calls an API gateway which routes to three microservices:
auth, products, and orders. All services write to PostgreSQL.
</figcaption>
</figure>
<!-- Form labels: explicit association is most robust -->
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required
aria-describedby="email-hint email-error">
<span id="email-hint" class="hint">We'll never share your email.</span>
<span id="email-error" role="alert" hidden>
Please enter a valid email address.
</span>7. Audit accessibility with axe-core and Lighthouse
# Lighthouse CLI audit
npx lighthouse https://your-site.com --only-categories=accessibility --output=html
# axe CLI scan
npx axe https://your-site.com// axe-core in Jest / Vitest with Testing Library
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Modal has no accessibility violations', async () => {
const { container } = render(
<Dialog open title="Confirm" onClose={() => {}}>
<p>Are you sure?</p>
<button>Cancel</button>
<button>Confirm</button>
</Dialog>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});// axe-core standalone audit (browser console or Playwright)
import axe from 'axe-core';
const results = await axe.run(document.body);
results.violations.forEach(v => {
console.error(`[${v.impact}] ${v.description}`);
v.nodes.forEach(n => console.error(' ', n.html));
});Manual audit checklist beyond automated tools:
- Tab through every interactive element - reachable? Visible focus? Logical order?
- Activate all controls with Enter/Space - do they work without a mouse?
- Open every modal/overlay - focus trapped? Escape closes? Focus returns to trigger?
- Resize to 400% zoom - content still readable and operable?
- Test with VoiceOver (macOS: Cmd+F5) or NVDA (Windows, free) for announcement quality
Load
references/aria-patterns.mdfor complete widget patterns with keyboard interactions.
Anti-patterns
| Anti-pattern | Why it fails | Correct approach |
|---|---|---|
<div onclick="..."> as button |
No keyboard support, no semantics, not announced as button | Use <button> - it is keyboard focusable, activatable with Space/Enter, and announced correctly |
role="button" on a <div> |
You still must add tabindex="0", keydown for Enter/Space, and all ARIA states manually |
Use <button> - you get all of this for free |
aria-hidden="true" on a focused element |
Removes element from AT while it has focus - keyboard users are trapped in a void | Never apply aria-hidden to an element that can receive focus |
placeholder as the only label |
Placeholder disappears on focus, fails contrast requirements, not reliably announced | Always use a visible <label> associated via for/id |
tabindex="2" or higher |
Creates a parallel tab order separate from DOM order - unpredictable and hard to maintain | Use tabindex="0" (natural order) or tabindex="-1" (programmatic only) |
| No focus indicator | Keyboard users cannot see where they are on the page; violates WCAG 2.4.7 | Use :focus-visible with a high-contrast outline; never outline: none without a visible replacement |
| Emojis as functional icons | Screen readers announce emoji names inconsistently ("red circle" vs "error"); rendering varies by OS; no contrast or size control | Use SVG icons from Lucide React, Heroicons, Phosphor, or Font Awesome with proper aria-label or aria-hidden |
Gotchas
aria-hidden="true"on a focusable element creates a keyboard trap - Screen readers skip the element, but keyboard focus still lands on it. The user is stuck on something invisible. Never applyaria-hiddento any element that can receive focus; removetabindexor useinertinstead.role="button"without keyboard handlers does nothing - Addingrole="button"to a<div>tells screen readers it's a button, but doesn't add keyboard activation. You must also addtabindex="0"and handle bothEnterandSpacekeydown events. Just use<button>instead.Live regions must be in the DOM before content is injected -
aria-liveregions only announce changes that happen after they're rendered. If you inject the region and its content at the same time, screen readers won't announce it. Render the empty live region on page load, then populate it.Focus return after modal close is not automatic - When a modal closes, focus goes to
<body>by default. Users lose their place in the page. Always storedocument.activeElementbefore opening a modal and call.focus()on that element when the modal closes.Automated tools catch ~30% of violations - axe and Lighthouse pass does not mean WCAG compliant. Focus order, announcement quality, color-alone information encoding, and logical reading order all require manual testing with a screen reader (VoiceOver on macOS, NVDA on Windows).
References
For detailed patterns and widget specifications, load the relevant reference:
references/aria-patterns.md- Complete ARIA widget patterns: combobox, menu, tree, listbox, accordion, tooltip with correct roles, states, and keyboard interactionsreferences/widget-examples.md- Accessible Dialog (Modal) and Tabs implementations with focus trapping and roving tabindex
Only load reference files when the current task requires that depth - they contain dense technical detail.
References
aria-patterns.md
ARIA Widget Patterns Reference
This reference documents correct ARIA roles, required states/properties, and keyboard interaction models for common custom widgets. Source: W3C ARIA Authoring Practices Guide (APG) 1.2 - https://www.w3.org/WAI/ARIA/apg/
General Rules
Before implementing any ARIA widget:
- Check if a native HTML element covers the use case (
<select>,<details>,<dialog>) - If building custom: implement all keyboard interactions in the spec - partial is broken
- Test with at least VoiceOver (macOS) and NVDA (Windows) before shipping
- ARIA only affects the accessibility tree - you must write keyboard behavior manually
Combobox (Autocomplete / Select)
Roles: combobox on the input, listbox on the popup, option on each item
Required ARIA:
aria-expandedon combobox:truewhen list is visible,falsewhen hiddenaria-haspopup="listbox"on comboboxaria-autocomplete:"list"(filters),"none"(no filtering),"both"(filters + inline)aria-controlspointing to the listbox idaria-activedescendanton combobox, set to the id of the focused option
Keyboard interactions:
| Key | Action |
|---|---|
| Down Arrow | Open popup if closed; move focus to next option |
| Up Arrow | Move focus to previous option |
| Enter | Select focused option; close popup |
| Escape | Close popup; clear selection or restore previous value |
| Alt + Down Arrow | Open popup without moving focus |
| Alt + Up Arrow | Close popup; keep focused option selected |
| Printable characters | Filter list; move to first matching option |
| Home / End | Move to first / last option |
<label for="fruit-input">Fruit</label>
<input
id="fruit-input"
type="text"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="fruit-listbox"
aria-activedescendant=""
autocomplete="off"
>
<ul id="fruit-listbox" role="listbox" aria-label="Fruits" hidden>
<li role="option" id="opt-apple">Apple</li>
<li role="option" id="opt-banana">Banana</li>
<li role="option" id="opt-cherry" aria-selected="true">Cherry</li>
</ul>Menu / Menu Button
Roles: button on trigger, menu on container, menuitem / menuitemcheckbox / menuitemradio on items
Required ARIA:
aria-haspopup="menu"on the trigger buttonaria-expandedon the trigger:truewhen open,falsewhen closedaria-controlson trigger pointing to menu id
Keyboard interactions:
| Key | Action |
|---|---|
| Enter / Space | Open menu; focus first item |
| Down Arrow | Open menu (if closed); move to next item |
| Up Arrow | Move to previous item; wraps to last |
| Home / End | Focus first / last item |
| Escape | Close menu; return focus to trigger |
| Tab | Close menu; move focus out of menu |
| Printable character | Move focus to next item starting with that character |
<button type="button" id="menu-trigger"
aria-haspopup="menu" aria-expanded="false" aria-controls="actions-menu">
Actions
</button>
<ul id="actions-menu" role="menu" aria-labelledby="menu-trigger" hidden>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="separator" aria-hidden="true"></li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>Note: Only the focused item has tabindex="0" (roving tabindex). All others have tabindex="-1".
Accordion
Roles: button on each header (inside an <h2>–<h6>), no special role on panels
Required ARIA:
aria-expandedon each trigger button:true/falsearia-controlson button pointing to panel ididon panel;aria-labelledbypointing to trigger button id (optional but recommended)
Keyboard interactions:
| Key | Action |
|---|---|
| Enter / Space | Toggle panel open/closed |
| Tab | Move focus to next focusable element (standard tab order) |
| Shift + Tab | Move focus to previous focusable element |
| Down Arrow (optional) | Move focus to next accordion header |
| Up Arrow (optional) | Move focus to previous accordion header |
| Home (optional) | Focus first accordion header |
| End (optional) | Focus last accordion header |
<div class="accordion">
<h3>
<button type="button" aria-expanded="true" aria-controls="panel-1" id="btn-1">
Section 1
</button>
</h3>
<div id="panel-1" role="region" aria-labelledby="btn-1">
<p>Panel content...</p>
</div>
<h3>
<button type="button" aria-expanded="false" aria-controls="panel-2" id="btn-2">
Section 2
</button>
</h3>
<div id="panel-2" role="region" aria-labelledby="btn-2" hidden>
<p>Panel content...</p>
</div>
</div>Tooltip
Role: tooltip on the tooltip container
Required ARIA:
aria-describedbyon the trigger element pointing to the tooltip id- Do NOT use
aria-labelledbyfor tooltips - they supplement, not replace, the accessible name
Keyboard interactions:
- Tooltip appears on focus and hover
- Escape dismisses tooltip (WCAG 1.4.13 - content on hover/focus must be dismissible)
- Tooltip must remain visible when pointer is moved over it
<button type="button" aria-describedby="tooltip-save">
<svg aria-hidden="true"><!-- save icon --></svg>
Save
</button>
<div role="tooltip" id="tooltip-save" hidden>
Save your changes (Ctrl+S)
</div>Note: Never put interactive content (links, buttons) inside a tooltip. Use a dialog or popover for interactive overlays.
Listbox
Role: listbox on container, option on each item
Required ARIA:
aria-selectedon each option:true/falsearia-multiselectable="true"if multiple selection is allowedaria-labelledbyoraria-labelon the listboxaria-activedescendanton the listbox element pointing to the focused option id
Keyboard interactions:
| Key | Action |
|---|---|
| Down / Up Arrow | Move focus to next / previous option |
| Home / End | Focus first / last option |
| Enter / Space | Select focused option |
| Shift + Down/Up | Extend selection (multiselect) |
| Ctrl + A | Select all (multiselect) |
| Printable character | Jump to next option starting with character |
<ul role="listbox" id="size-listbox" aria-label="T-shirt size"
aria-activedescendant="size-m" tabindex="0">
<li role="option" id="size-s" aria-selected="false">Small</li>
<li role="option" id="size-m" aria-selected="true">Medium</li>
<li role="option" id="size-l" aria-selected="false">Large</li>
</ul>Tree (Hierarchical List)
Roles: tree on root, treeitem on each item, group on nested lists
Required ARIA:
aria-expandedon treeitem nodes that have children:true(open) /false(closed)- Leaf nodes do not have
aria-expanded aria-selectedfor selection statearia-level,aria-posinset,aria-setsizefor virtual trees (not rendered in DOM)
Keyboard interactions:
| Key | Action |
|---|---|
| Down / Up Arrow | Move focus to next / previous visible item |
| Right Arrow | Expand node (if collapsed); move to first child (if expanded) |
| Left Arrow | Collapse node (if expanded); move to parent (if collapsed) |
| Home / End | Focus first / last visible item |
| Enter | Activate / select item |
| Printable character | Jump to next item starting with character |
<ul role="tree" aria-label="File system">
<li role="treeitem" aria-expanded="true">
<span>src/</span>
<ul role="group">
<li role="treeitem" aria-expanded="false">
<span>components/</span>
<ul role="group">
<li role="treeitem">Button.tsx</li>
</ul>
</li>
<li role="treeitem">index.ts</li>
</ul>
</li>
</ul>Alert / Status
Roles: alert (assertive, interrupts), status (polite, waits)
When to use:
role="alert"/aria-live="assertive"- errors, session expiry warnings, destructive action confirmations. Interrupts the screen reader immediately.role="status"/aria-live="polite"- form save confirmations, search result counts, progress updates. Waits until the user pauses.
Critical rules:
- Inject text content into a pre-existing container. Screen readers register live regions at page load - dynamically created live regions are unreliable.
aria-atomic="true"announces the full region content as one unit (use for status messages)aria-relevant="additions"(default) announces only additions;"all"announces additions and removals
<!-- Persistent containers, created in initial HTML -->
<div id="sr-alert" role="alert" aria-live="assertive" aria-atomic="true"></div>
<div id="sr-status" role="status" aria-live="polite" aria-atomic="true"></div>// To announce: inject text into the pre-existing container
function announce(message, type = 'polite') {
const el = document.getElementById(type === 'assertive' ? 'sr-alert' : 'sr-status');
el.textContent = '';
// Brief timeout allows re-announcement of the same message
requestAnimationFrame(() => { el.textContent = message; });
}
announce('Changes saved successfully');
announce('Error: Network request failed. Please retry.', 'assertive');Progress Bar / Spinner
Role: progressbar
Required ARIA:
aria-valuenow- current value (omit for indeterminate)aria-valuemin,aria-valuemax- range (typically 0 and 100)aria-valuetext- human-readable label e.g. "3 of 10 files uploaded"aria-labeloraria-labelledbyfor context
<!-- Determinate -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="65% - uploading file 3 of 5" aria-label="Upload progress">
<div style="width: 65%"></div>
</div>
<!-- Indeterminate (no aria-valuenow) -->
<div role="progressbar" aria-label="Loading results">
<!-- animated spinner -->
</div>Switch (Toggle)
Role: switch (subclass of checkbox; specifically for on/off state)
Required ARIA:
aria-checked:"true"/"false"
Keyboard:
- Space toggles the switch
- Enter (optionally) also toggles
<!-- Preferred: native checkbox styled as switch -->
<label class="switch">
<input type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span>
Enable notifications
</label>Note: prefer <input type="checkbox" role="switch"> over a custom <button role="switch"> - the checkbox handles aria-checked state automatically via the checked property.
Disclosure (Show/Hide)
The simplest expand/collapse pattern - a button that reveals or hides content.
<button type="button" aria-expanded="false" aria-controls="details-panel">
Show details
</button>
<div id="details-panel" hidden>
<p>Additional information...</p>
</div>const btn = document.querySelector('[aria-controls="details-panel"]');
const panel = document.getElementById('details-panel');
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
});No special keyboard beyond the native button behavior (Enter/Space). This is distinct from Accordion only in that Accordion wraps triggers in heading elements.
Common Pitfalls Summary
| Pattern | Common mistake | Correct behavior |
|---|---|---|
| Combobox | Forgetting aria-activedescendant |
Update it on every option focus change |
| Menu | Using Tab to navigate menu items | Tab must close menu; Arrow keys navigate items |
| Dialog | Not returning focus to trigger on close | Always store and restore trigger focus |
| Tooltip | Interactive content inside tooltip | Use popover or dialog for interactive overlays |
| Live region | Creating region dynamically | Create region in initial HTML; inject content later |
| Tree | Applying aria-expanded to leaf nodes |
Only nodes with children get aria-expanded |
| Listbox | Missing aria-activedescendant |
Screen readers cannot track which option is "focused" |
widget-examples.md
Accessible Widget Examples
Accessible Dialog (Modal)
function Dialog({
open, onClose, title, description, children
}: {
open: boolean; onClose: () => void;
title: string; description?: string; children: React.ReactNode;
}) {
const dialogRef = React.useRef<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element inside dialog
const focusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
} else {
previousFocusRef.current?.focus();
}
}, [open]);
// Trap focus inside dialog
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab') return;
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
) ?? []
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
if (!open) return null;
return (
<div role="dialog" aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby={description ? 'dialog-desc' : undefined}
ref={dialogRef} onKeyDown={handleKeyDown}
>
<h2 id="dialog-title">{title}</h2>
{description && <p id="dialog-desc">{description}</p>}
{children}
<button onClick={onClose}>Close</button>
</div>
);
}Accessible Tabs
function Tabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [selected, setSelected] = React.useState(0);
const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, i: number) => {
let next = i;
if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
setSelected(next);
tabRefs.current[next]?.focus();
};
return (
<>
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={i === selected}
aria-controls={`panel-${tab.id}`}
tabIndex={i === selected ? 0 : -1}
ref={(el) => { tabRefs.current[i] = el; }}
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => setSelected(i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={i !== selected}
>
{tab.content}
</div>
))}
</>
);
} Frequently Asked Questions
What is accessibility-wcag?
Use this skill when implementing web accessibility, adding ARIA attributes, ensuring keyboard navigation, or auditing WCAG compliance. Triggers on accessibility, a11y, ARIA roles, screen readers, keyboard navigation, focus management, color contrast, alt text, semantic HTML, and any task requiring WCAG 2.2 compliance or inclusive design.
How do I install accessibility-wcag?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill accessibility-wcag in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support accessibility-wcag?
accessibility-wcag works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.