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.
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 states
-
web-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
Checklist
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
-
WCAG 2.1 Quick Reference
-
WebAIM Contrast Checker
-
axe DevTools Browser Extension
-
VoiceOver User Guide
-
NVDA Screen Reader