| name | accessibility |
| description | Use when building or reviewing UI components to ensure WCAG 2.1 compliance, keyboard navigation, semantic HTML, ARIA attributes, color contrast, and assistive technology support. |
UI Accessibility (WCAG 2.1)
Overview
This skill guides implementation and review of accessible UI components following WCAG 2.1 Level AA standards. Accessibility ensures your interface is usable by everyone—keyboard users, screen reader users, users with low vision, and users with cognitive disabilities.
Core principle: Semantic HTML first, ARIA last. Keyboard navigation and screen reader support are non-negotiable.
When to Use / When NOT to Use
✅ Use This Skill When
- Building new UI components (buttons, modals, dropdowns, forms, tabs)
- Adding keyboard navigation or focus management
- Reviewing code for accessibility violations
- Testing with screen readers or keyboard-only
- Ensuring color contrast and visual focus indicators
- Adding ARIA attributes to dynamic content
❌ Do NOT Use When
- Component is purely decorative (no interaction needed)
- Audience explicitly doesn't include users with accessibility needs (internally impossible—always assume diverse users)
- You're skipping automated testing due to time constraints (violates DoD)
Core Concepts
1. Semantic HTML Structure
Foundation: Use meaningful HTML elements to convey structure and purpose.
<header role="banner">
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<article>
<h1>Page Title</h1>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer>
<p>© 2026 Company</p>
</footer>
<h1>Main Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
<div class="header">
<div class="nav">
<div>Home</div>
<div>About</div>
</div>
</div>
<div class="main">
<div class="title">Page Title</div>
</div>
<h1>Main Title</h1>
<h3>Jumps to h3 (breaks hierarchy)</h3>
Label Association Rules:
<label for="email-input">Email Address</label>
<input id="email-input" type="email" required />
<label>
Email Address
<input type="email" required />
</label>
<input type="email" placeholder="Enter email" />
<label>Email Address</label>
<input type="email" />
2. Keyboard Navigation
Principle: All interactive features usable via keyboard—Tab, Shift+Tab, Enter, Space, Arrow keys, Escape.
Key Bindings:
- Tab / Shift+Tab: Move focus forward/backward
- Enter / Space: Activate buttons, toggle checkboxes
- Arrow keys: Navigate lists, menus, sliders, tabs (up/down for vertical; left/right for horizontal)
- Home / End: Jump to first/last item in list
- Escape: Close modal, dropdown, or menu
Focus Management:
<button tabindex="0">Click me</button>
<div tabindex="-1" id="skip-target">Skip to main content</div>
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
button:focus {
outline: none; /* NEVER DO THIS */
}
Skip Links (Focus Management):
<a href="#main-content" class="skip-link">Skip to main content</a>
<style>
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 0;
top: 0;
background: #000;
color: #fff;
padding: 8px 12px;
}
</style>
<main id="main-content" tabindex="-1">
</main>
3. ARIA Attributes
Golden Rule: Semantic HTML first. Use ARIA only when native HTML doesn't exist or to enhance semantics.
Common ARIA Attributes
| Attribute | Purpose | Example |
|---|
aria-label | Names element (for icons, buttons without text) | <button aria-label="Close menu">×</button> |
aria-labelledby | Links to another element as label | <h2 id="dialog-title">Confirm Delete</h2><div aria-labelledby="dialog-title">... |
aria-describedby | Adds descriptive text (error, hint) | <input aria-describedby="pwd-hint" /><p id="pwd-hint">Min 8 chars |
aria-live | Announces dynamic content ("polite" or "assertive") | <div aria-live="polite">Item added to cart</div> |
aria-hidden | Hides from screen readers (decorative only) | <span aria-hidden="true">→</span> |
role | Semantic role | <div role="dialog">... |
aria-expanded | Indicates expanded/collapsed state | <button aria-expanded="false">Menu</button> |
aria-haspopup | Signals a popup exists | <button aria-haspopup="menu">Options</button> |
aria-selected | Current selection in list/tabs | <button role="tab" aria-selected="true">Tab 1</button> |
aria-modal | Marks modal dialogs | <div role="dialog" aria-modal="true">... |
aria-required | Flags required form fields | <input aria-required="true" /> |
aria-invalid | Indicates validation error | <input aria-invalid="true" aria-describedby="error-msg" /> |
aria-atomic | Reads entire content block | <div aria-live="polite" aria-atomic="true">Status: Updated</div> |
4. Color Contrast & Visual Focus
WCAG Contrast Ratios:
| Level | Normal Text | Large Text (18pt+) |
|---|
| AA (minimum) | 4.5:1 | 3:1 |
| AAA (enhanced) | 7:1 | 4.5:1 |
Visual Focus Indicators (Required):
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
button:focus {
background-color: #e6f2ff;
box-shadow: inset 0 0 0 2px #0066cc;
}
button:focus {
outline: none;
}
button:focus {
outline: 1px solid #ccc;
}
Never Convey Info by Color Alone:
<span style="color: red;">This field is required</span>
<span style="color: red;">
<span aria-hidden="true">*</span> This field is required
</span>
<input type="checkbox" style="border: 2px solid red;" />
<label>I agree (required)</label>
5. Testing & Validation
Automated Tools:
- axe DevTools: Browser extension for violations (Chrome, Firefox)
- Lighthouse: Chrome DevTools → Lighthouse → Accessibility (target: 90+)
- Pa11y: CLI tool for automated scanning
- jest-axe: Automated testing in Jest
Manual Testing:
- Keyboard-only: Unplug mouse; navigate using Tab, Enter, Space, arrows
- Screen readers: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
- Color contrast: WebAIM Contrast Checker
- Zoom: Test at 200% zoom; ensure nothing breaks
Acceptance Criteria:
- Zero axe DevTools violations
- Lighthouse Accessibility ≥ 90
- All features functional via keyboard
- All images have alt text
- All form inputs have labels
Component Patterns
Pattern 1: Accessible Dropdown Menu
import { useState, useRef, useEffect } from 'react';
export function AccessibleDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef(null);
const menuRef = useRef(null);
const options = ['Edit', 'Duplicate', 'Delete'];
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
const handleMenuKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((i) => (i + 1) % options.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((i) => (i - 1 + options.length) % options.length);
break;
case 'Home':
e.preventDefault();
setSelectedIndex(0);
break;
case 'End':
e.preventDefault();
setSelectedIndex(options.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
handleSelect(options[selectedIndex]);
break;
default:
break;
}
};
const handleSelect = (option) => {
console.log('Selected:', option);
setIsOpen(false);
buttonRef.current?.focus();
};
return (
<div className="dropdown">
<button
ref={buttonRef}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="dropdown-menu"
onClick={() => setIsOpen(!isOpen)}
>
Options
</button>
{isOpen && (
<ul
id="dropdown-menu"
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
>
{options.map((option, i) => (
<li key={option} role="none">
<button
role="menuitem"
aria-selected={i === selectedIndex}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => handleSelect(option)}
>
{option}
</button>
</li>
))}
</ul>
)}
</div>
);
}
Pattern 2: Accessible Modal Dialog
import { useEffect, useRef } from 'react';
export function AccessibleModal({ isOpen, onClose, title, children }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const previouslyFocused = document.activeElement;
dialogRef.current?.focus();
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
previouslyFocused?.focus();
return;
}
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
className="modal"
tabIndex="-1"
>
<h2 id="modal-title">{title}</h2>
<div id="modal-description">{children}</div>
<button onClick={onClose}>Close</button>
</div>
</>
);
}
Pattern 3: Accessible Alert/Notification
export function AccessibleAlert({ type, message, onDismiss }) {
const isError = type === 'error';
return (
<div
role="alert"
aria-live={isError ? 'assertive' : 'polite'}
aria-atomic="true"
className={`alert alert-${type}`}
>
<span aria-hidden="true">
{type === 'error' && '⚠️'}
{type === 'success' && '✓'}
{type === 'warning' && '⚡'}
{type === 'info' && 'ℹ️'}
</span>
{message}
{onDismiss && (
<button
aria-label="Dismiss notification"
onClick={onDismiss}
>
×
</button>
)}
</div>
);
}
Pattern 4: Accessible Form with Error Handling
import { useState } from 'react';
export function AccessibleForm() {
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Email must follow format: user@domain.com';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
console.log('Form submitted:', formData);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
{/* Email Field */}
<div className="form-group">
<label htmlFor="email-input">
Email Address <span aria-hidden="true">*</span>
</label>
<input
id="email-input"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Password Field */}
<div className="form-group">
<label htmlFor="password-input">
Password <span aria-hidden="true">*</span>
</label>
<input
id="password-input"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
<p id="password-hint" className="hint">
Minimum 8 characters
</p>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<button type="submit">Sign In</button>
</form>
);
}
Pattern 5: Accessible Tab Component
import { useState } from 'react';
export function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{ label: 'Overview', content: 'Overview content here' },
{ label: 'Details', content: 'Details content here' },
{ label: 'Related', content: 'Related items here' },
];
const handleTabKeyDown = (e, index) => {
let nextIndex = index;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'ArrowRight':
e.preventDefault();
nextIndex = (index + 1) % tabs.length;
break;
case 'Home':
e.preventDefault();
nextIndex = 0;
break;
case 'End':
e.preventDefault();
nextIndex = tabs.length - 1;
break;
default:
return;
}
setActiveTab(nextIndex);
document.getElementById(`tab-${nextIndex}`)?.focus();
};
return (
<div className="tabs">
{/* Tablist */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, i) => (
<button
key={i}
id={`tab-${i}`}
role="tab"
aria-selected={activeTab === i}
aria-controls={`tabpanel-${i}`}
tabIndex={activeTab === i ? 0 : -1}
onClick={() => setActiveTab(i)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{/* Tabpanels */}
{tabs.map((tab, i) => (
<div
key={i}
id={`tabpanel-${i}`}
role="tabpanel"
aria-labelledby={`tab-${i}`}
hidden={activeTab !== i}
>
{tab.content}
</div>
))}
</div>
);
}
Pattern 6: Testing with axe-core and Jest
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { AccessibleForm } from './AccessibleForm';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
test('form has no accessibility violations', async () => {
const { container } = render(<AccessibleForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('form is keyboard navigable', () => {
render(<AccessibleForm />);
const emailInput = screen.getByLabelText('Email Address');
const passwordInput = screen.getByLabelText('Password');
const submitButton = screen.getByRole('button', { name: /sign in/i });
emailInput.focus();
expect(document.activeElement).toBe(emailInput);
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
emailInput.dispatchEvent(tabEvent);
submitButton.focus();
const spaceEvent = new KeyboardEvent('keydown', { key: ' ' });
submitButton.dispatchEvent(spaceEvent);
});
test('modal traps focus', () => {
const { rerender } = render(
<AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal">
<button>Action 1</button>
<button>Action 2</button>
</AccessibleModal>
);
const buttons = screen.getAllByRole('button');
const lastButton = buttons[buttons.length - 1];
lastButton.focus();
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false,
});
expect(lastButton === document.activeElement);
});
test('alert is announced by screen readers', () => {
render(
<AccessibleAlert
type="success"
message="Item saved successfully"
/>
);
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'polite');
expect(alert).toHaveAttribute('aria-atomic', 'true');
});
});
Quick Reference Tables
Keyboard Events Mapping
| User Action | Key(s) | Component | Purpose |
|---|
| Navigate | Tab / Shift+Tab | All | Focus previous/next |
| Activate | Enter / Space | Button, Checkbox, Radio | Trigger action |
| Navigate list | ↑↓ | Menu, Listbox, Select | Move between items |
| Navigate horizontal | ← → | Tabs, Slider, Menu | Move between options |
| Go to start | Home | Tabs, List, Slider | Jump to first item |
| Go to end | End | Tabs, List, Slider | Jump to last item |
| Close/Cancel | Escape | Modal, Dropdown, Menu | Dismiss overlay |
| Alphabetic | a–z | Listbox, Combobox | Jump to matching item |
ARIA Role Reference (Common)
| Role | Element | Usage |
|---|
button | <div>, <span> (non-button) | Make non-button interactive |
menu / menuitem | <ul>, <li> | Popup/context menu |
listbox / option | Custom dropdown | Accessible select replacement |
tab / tablist / tabpanel | Tab UI | Tab container and panels |
dialog | Modal / overlay | Modal dialog (+ aria-modal) |
alert | Notification | Assertive/polite announcement |
region | Generic section | Landmark region (aria-label required) |
navigation | Nav links | Main/secondary navigation |
Contrast Ratio Quick Check
| Scenario | Min Ratio | Example |
|---|
| Body text (AA) | 4.5:1 | #333 on #fff = 12.6:1 ✓ |
| Large text (AA) | 3:1 | #666 on #fff = 5.74:1 ✓ |
| Icon/UI (AA) | 3:1 | #0066cc on #fff = 8.59:1 ✓ |
| Body text (AAA) | 7:1 | #555 on #fff = 9.26:1 ✓ |
Review Checklist
Use this checklist before marking a component accessible:
Semantic HTML
Keyboard Navigation
ARIA
Visual & Color
Testing
Common Mistakes
❌ Mistake 1: Placeholder as Label
<input type="email" placeholder="Enter your email" />
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Why: Placeholders are hidden when users focus the input. Screen reader users won't hear what the field is for.
❌ Mistake 2: outline: none Without Replacement
button:focus {
outline: none;
}
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Why: Keyboard users rely on focus indicators to navigate. Removing it breaks navigation.
❌ Mistake 3: Positive tabindex
<button tabindex="2">Second (confusing!)</button>
<button tabindex="1">First</button>
<button>Third (where does it go?)</button>
<button>First</button>
<button>Second</button>
<button tabindex="-1">Hidden from tab order</button>
Why: Positive tabindex disrupts the natural DOM order. Screen reader and keyboard users get confused.
❌ Mistake 4: Color Alone to Convey Info
<input style="border: 1px solid red;" />
<label>
Email <span aria-hidden="true">*</span>
</label>
<input />
Why: Users with color blindness can't see the distinction. Pair color with icons or text.
❌ Mistake 5: No Focus Trap in Modal
<div role="dialog">
<button>Close</button>
</div>
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
}
};
document.addEventListener('keydown', handleKeyDown);
}, []);
Why: Keyboard users can accidentally tab out of a modal, losing context.
❌ Mistake 6: Missing Alt Text on Images
<img src="chart.png" />
<img src="chart.png" alt="Sales increased 25% in Q3" />
<img src="spacer.png" alt="" aria-hidden="true" />
Why: Screen reader users hear "image, chart-dot-png" with no context. Provide descriptive alt text.
❌ Mistake 7: No Error Messages Linked to Fields
<input type="email" />
<span>Invalid email format</span>
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">Invalid email format: user@domain.com</span>
Why: Screen reader users won't know the error is related to the input. Link it explicitly.
❌ Mistake 8: No Announce for Dynamic Content
<div>Items in cart: 5</div>
<div aria-live="polite" aria-atomic="true">
Item added to cart. Items: 5
</div>
Why: Screen reader users don't hear dynamic updates. Use aria-live to announce.
Summary
Accessibility is not a feature—it's a requirement. Every component must be:
- Keyboard accessible — All features work without a mouse
- Screen reader friendly — Semantic HTML + ARIA
- Visually accessible — Sufficient contrast and focus indicators
- Well tested — Axe, Lighthouse, manual keyboard, screen reader
Start with semantic HTML. Add ARIA only when needed. Test with real tools and real users.
Last updated: 2026-03-30
Version: 1.0.0