web-accessibility
Web Accessibility Skill
Version: 1.0 Standard: WCAG 2.1 Level AA
Accessibility is not optional. These patterns ensure all users can use your application.
The Problem
AI agents build interfaces that look correct but aren't accessible. Without explicit WCAG standards, each session skips alt text, uses divs instead of buttons, nests headings incorrectly, and omits keyboard navigation — not from intent but from path-of-least-resistance. Each session introduces slightly different accessibility gaps, making remediation a moving target. These standards ensure accessibility is built in from the first element.
Consumption
- Builders: Read
## Builder Checklistbefore writing any HTML or component markup. Accessibility must be designed in, not patched via ARIA after the fact. - Refactorers: Use
## Enforced Rulesto find accessibility violations. Read narrative sections for remediation patterns. - Both: Narrative sections are the authoritative standard. Checklist and rules table are compressed views of the same content.
Scope and Boundaries
This skill covers:
- WCAG 2.1 Level AA compliance
- POUR principles (Perceivable, Operable, Understandable, Robust)
- Semantic HTML for accessibility (landmarks, headings, buttons vs links)
- Keyboard navigation and focus management
- Focus trapping for modals
- ARIA usage patterns and roles
- Form accessibility (labels, errors, required fields)
- Color contrast requirements
- Images and media alt text
- Dynamic content and live regions
- Reduced motion support
- Screen reader testing
Defers to other skills:
design: Design token system, overall design principles, component statesweb-css: CSS implementation details, file organization, responsive breakpoints
Use this skill when: You need WCAG compliance, screen reader support, focus management, or ARIA patterns. Use design when: You need design system principles, token enforcement, or layout philosophy. Use web-css when: You need CSS architecture or responsive implementation.
Core Principles (POUR)
- Perceivable — Users can perceive all content (see, hear, or feel it).
- Operable — Users can operate all controls (keyboard, mouse, voice, etc.).
- Understandable — Users can understand content and interface behavior.
- Robust — Content works with current and future assistive technologies.
Semantic HTML First
Use the Right Element
<!-- ✅ Good - Semantic elements -->
<header>Site header</header>
<nav>Navigation</nav>
<main>
<article>
<h1>Article Title</h1>
<p>Content...</p>
</article>
<aside>Related content</aside>
</main>
<footer>Site footer</footer>
<!-- ❌ Bad - Div soup -->
<div class="header">Site header</div>
<div class="nav">Navigation</div>
<div class="main">
<div class="article">
<div class="title">Article Title</div>
<div class="content">Content...</div>
</div>
</div>
Buttons vs Links
// ✅ Button - Performs an action
<button onClick={handleSubmit}>Submit Form</button>
<button onClick={openModal}>Open Settings</button>
// ✅ Link - Navigates somewhere
<a href="/products">View Products</a>
<Link to="/checkout">Proceed to Checkout</Link>
// ❌ Bad - Wrong semantics
<div onClick={handleSubmit}>Submit Form</div>
<a onClick={openModal}>Open Settings</a> {/* No href! */}
<button onClick={() => navigate('/products')}>View Products</button>
Heading Hierarchy
// ✅ Good - Proper hierarchy
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
<h3>Subsection</h3>
</section>
<section>
<h2>Another Section</h2>
</section>
// ❌ Bad - Skipped levels
<h1>Page Title</h1>
<h3>Subsection</h3> {/* Skipped h2! */}
<h5>Deep section</h5> {/* Skipped h4! */}
Keyboard Navigation
Focus Management
/* Visible focus indicator */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove outline only for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
Tab Order
// ✅ Good - Logical tab order (follows DOM order)
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" type="password" />
<button type="submit">Login</button>
</form>
// ❌ Bad - Jumpy tab order
<form>
<button type="submit" tabIndex={1}>Login</button>
<input tabIndex={3} />
<input tabIndex={2} />
</form>
Skip Links
// At the very top of your app
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
Skip to main content
</a>
);
}
// CSS
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
z-index: var(--z-tooltip);
}
.skip-link:focus {
top: var(--space-4);
}
Keyboard Shortcuts
// Listen for keyboard events
function SearchModal({ isOpen, onClose }) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose();
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
// ...
}
Focus Trapping (Modals)
Modal Pattern
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
// Store current focus
previousFocus.current = document.activeElement;
// Focus the modal
modalRef.current?.focus();
// Trap focus inside modal
const modal = modalRef.current;
const focusableElements = modal?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements?.[0];
const lastElement = focusableElements?.[focusableElements.length - 1];
function handleTab(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
modal?.addEventListener('keydown', handleTab);
return () => modal?.removeEventListener('keydown', handleTab);
} else {
// Restore focus when closing
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
</div>,
document.body
);
}
ARIA Usage
When to Use ARIA
First rule of ARIA: Don't use ARIA if you can use native HTML.
// ❌ Unnecessary ARIA
<div role="button" tabIndex={0} onClick={handleClick}>
Click me
</div>
// ✅ Just use a button
<button onClick={handleClick}>Click me</button>
Essential ARIA Patterns
// Labeling
<button aria-label="Close menu">×</button>
<input aria-labelledby="name-label helper-text" />
// Descriptions
<button aria-describedby="delete-warning">Delete Account</button>
<p id="delete-warning">This action cannot be undone.</p>
// States
<button aria-pressed={isActive}>Toggle</button>
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
<div id="menu" aria-hidden={!isOpen}>Menu content</div>
// Live regions (for dynamic content)
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
Common ARIA Roles
| Role | Use Case |
|---|---|
alert |
Important messages (errors, warnings) |
alertdialog |
Modal requiring user response |
dialog |
Modal dialogs |
navigation |
Navigation sections |
search |
Search forms |
tablist, tab, tabpanel |
Tab interfaces |
menu, menuitem |
Dropdown menus |
status |
Status updates (loading, saving) |
Forms
Labels
// ✅ Explicit label association
<label htmlFor="email">Email Address</label>
<input id="email" type="email" />
// ✅ Implicit association (wrapped)
<label>
Email Address
<input type="email" />
</label>
// ❌ Bad - No association
<span>Email Address</span>
<input type="email" />
Error Messages
function FormField({ id, label, error, ...props }) {
const errorId = `${id}-error`;
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="error-message" role="alert">
{error}
</p>
)}
</div>
);
}
Required Fields
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
<span className="visually-hidden">(required)</span>
</label>
<input id="name" required aria-required="true" />
Color and Contrast
Minimum Contrast Ratios
| Text Type | Ratio | Example |
|---|---|---|
| Normal text (< 18px) | 4.5:1 | Body copy |
| Large text (≥ 18px or 14px bold) | 3:1 | Headings |
| UI components | 3:1 | Buttons, inputs |
Don't Rely on Color Alone
// ❌ Bad - Color is only indicator
<span style={{ color: error ? 'red' : 'green' }}>
{error ? 'Invalid' : 'Valid'}
</span>
// ✅ Good - Color + icon + text
<span className={error ? 'error' : 'success'}>
{error ? (
<>
<ErrorIcon aria-hidden="true" /> Invalid: {error}
</>
) : (
<>
<CheckIcon aria-hidden="true" /> Valid
</>
)}
</span>
Images and Media
Alt Text
// Informative image
<img src="chart.png" alt="Sales increased 40% from January to March" />
// Decorative image
<img src="divider.png" alt="" role="presentation" />
// Complex image
<figure>
<img src="diagram.png" alt="Architecture diagram" aria-describedby="diagram-desc" />
<figcaption id="diagram-desc">
The system consists of three layers: presentation, business logic, and data.
{/* Full description */}
</figcaption>
</figure>
SVG Icons
// Decorative icon (with visible text)
<button>
<SearchIcon aria-hidden="true" />
Search
</button>
// Standalone icon (needs label)
<button aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
// Icon with title
<svg role="img" aria-labelledby="icon-title">
<title id="icon-title">Search</title>
<path d="..." />
</svg>
Dynamic Content
Loading States
function ProductList() {
const { data, loading, error } = useQuery(GET_PRODUCTS);
if (loading) {
return (
<div role="status" aria-live="polite">
<Spinner aria-hidden="true" />
<span className="visually-hidden">Loading products...</span>
</div>
);
}
if (error) {
return (
<div role="alert">
Error loading products. Please try again.
</div>
);
}
return (
<ul aria-label="Products">
{data.products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Live Regions
// Polite - Waits for user to finish current task
<div aria-live="polite" aria-atomic="true">
{saveStatus} {/* "Saving...", "Saved!", etc. */}
</div>
// Assertive - Interrupts immediately (use sparingly)
<div aria-live="assertive" role="alert">
{errorMessage}
</div>
Reduced Motion
/* Respect user preference */
@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;
}
}
// In React
function AnimatedComponent() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
return (
<motion.div
animate={{ opacity: 1 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.3,
}}
/>
);
}
Visually Hidden Text
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Use for screen reader only content
<button>
<TrashIcon aria-hidden="true" />
<span className="visually-hidden">Delete item</span>
</button>
<a href="/products">
View all products
<span className="visually-hidden"> in the catalog</span>
</a>
Testing Accessibility
Automated Testing
// jest + jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<ProductCard product={mockProduct} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist
- Navigate entire page with keyboard only
- Use screen reader (VoiceOver, NVDA)
- Zoom to 200% - still usable?
- Check with browser accessibility inspector
- Test with high contrast mode
- Test with reduced motion enabled
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Divs for everything | No semantics for AT | Use semantic HTML |
| tabIndex > 0 | Breaks natural tab order | Remove positive tabIndex |
| Outline: none | No focus indicator | Use :focus-visible |
| ARIA overuse | Complex, error-prone | Native HTML first |
| Color-only meaning | Invisible to colorblind | Add icons, text |
| Auto-playing media | Disorienting, annoying | User-initiated only |
| Mouse-only interactions | Excludes keyboard users | Add keyboard handlers |
| Missing alt text | Images invisible to SR | Describe or mark decorative |
| Non-descriptive links | "Click here" is useless | Descriptive link text |
Builder Checklist
Before writing UI code governed by this skill, verify your plan against these constraints. Builders read this section before writing code; refactorers use the Enforced Rules table and full narrative instead.
Semantic HTML
- Correct heading hierarchy
- Buttons for actions, links for navigation
- Semantic landmarks (header, nav, main, footer)
- Lists use ul/ol/li
Keyboard
- All interactive elements focusable
- Visible focus indicators
- Logical tab order
- Skip link present
- Modals trap focus
Screen Readers
- All images have alt text
- Form inputs have labels
- Error messages linked to inputs
- Dynamic content uses live regions
- Icons have accessible names
Visual
- Color contrast meets 4.5:1
- Color is not only indicator
- Works at 200% zoom
- Reduced motion respected
Forms
- All inputs labeled
- Required fields indicated
- Errors clearly identified
- Error messages helpful
Enforced Rules
These rules are deterministically checked by check.js (clean-team). When updating these standards, update the corresponding check.js rules to match — and vice versa.
| Rule ID | Severity | What It Checks |
|---|---|---|
img-alt-required |
error | <img> without alt attribute |
title-required |
error | Missing <title> element |
tabindex-no-positive |
error | Positive tabindex values (breaks tab order) |
heading-order |
warn | Heading levels that skip (h1 → h3) |
single-h1 |
warn | Multiple <h1> elements per page |
no-div-as-button |
warn | <div>/<span> with onclick handler |
Resources
More from alexanderstephenthompson/claude-hub
design
Design and UI standards for accessibility, semantic HTML, and responsive layouts
25data-python
Python patterns for data processing - pandas, polars, pyspark
23organize
Folder organization system using the L1/L2/L3 cognitive-phase framework. Applies to home, work, and shared drives for designing folder hierarchies, reorganizing structures, or deciding where files belong.
21web-css
CSS architecture for vanilla CSS - organization, design tokens, responsive patterns
21code-quality
Non-negotiable code quality standards for testing, structure, naming, error handling, and documentation
21security
Security standards for authentication, input validation, and OWASP compliance
20