94.8% of the top million websites fail basic accessibility tests.
The average homepage has 51 WCAG violations - that’s one accessibility barrier every 24 elements. And it’s getting expensive:
- Lawsuits: Up 37% year-over-year
- EAA Penalties: Up to €80,000 (enforced since June 2025)
- ADA Title II: Deadline hits April 2026
If you’re shipping inaccessible UIs in 2026, you’re risking legal action.
I learned this the hard way. Three years ago, I shipped a component library for a fintech client that looked beautiful but was completely unusable with a screen reader. The modal component trapped focus incorrectly, the custom select dropdowns were keyboard-inaccessible, and we’d used divs with click handlers instead of buttons throughout.
The remediation took six weeks and cost the client a government contract they’d been counting on.
Since then, I’ve made accessibility a non-negotiable part of my workflow. I’ve audited dozens of component libraries and design systems, and the pattern is always the same.
Teams treat accessibility as a checklist item at the end of a sprint. It needs to be a constraint that shapes the design from the start.
As design engineers, we sit at the exact intersection where accessibility decisions get made or broken.
This guide covers what I wish I’d known before that fintech disaster: how to build accessibility into your Figma-to-code workflow, which component libraries actually get it right, and the specific patterns that trip up even experienced engineers.
TL;DR
- Use semantic HTML (
button,nav,main) instead of divs with click handlers - Color contrast minimum: 4.5:1 for text, 3:1 for UI components
- Every interactive element must be keyboard accessible (Tab, Enter, Space, Escape)
- Test with axe DevTools (catches 30-40%), then VoiceOver/NVDA (catches the rest)
- Use
prefers-reduced-motionfor all animations - Use Radix UI, React Aria, or Headless UI instead of building custom widgets
- Touch targets: 24px minimum (WCAG), 44px recommended (Apple)
What You’ll Learn
- How to audit design tokens for WCAG contrast compliance
- Semantic HTML vs ARIA: a decision framework
- Keyboard patterns for custom components (roving tabindex, focus trapping)
- Screen reader testing workflow for macOS and Windows
- Why accessibility overlays don’t work and what to do instead
The $13 Billion Accessibility Problem
Let’s skip the “accessibility is the right thing to do” speech. You already know that.
Here’s what matters for your next sprint planning:
WebAIM analyzed 1 million homepages. Here’s what they found:
| Metric | 2024 | 2025 | Trend |
|---|---|---|---|
| Pages with WCAG failures | 95.9% | 94.8% | Slight improvement |
| Average errors per page | 57 | 51 | -10.3% |
| Error rate per element | 4.3% | 4.1% | 1 barrier every 24 elements |
| Low contrast text | 81% | 78% | Still dominant |
| Missing alt text | 54% | 52% | Barely moving |
| Empty links | 45% | 44% | Stagnant |
The industry is getting marginally better, but 94.8% failure rate is still embarrassing. More importantly, it’s increasingly expensive.
The Legal Timeline You Need to Know
| Regulation | Deadline | Scope | Standard | Penalties |
|---|---|---|---|---|
| European Accessibility Act (EAA) | June 28, 2025 (Active) | EU digital products/services, 10+ employees, €2M+ revenue | EN 301 549 (WCAG 2.1 AA) | Up to €80,000 (€50,000 for SMEs) |
| ADA Title II | April 2026 | US state/local government sites | WCAG 2.1 AA | Lawsuits, injunctions |
| Section 508 Refresh | Active | US federal agencies | WCAG 2.0 AA | Contract loss |
| AODA | Active | Ontario businesses | WCAG 2.0 AA | Up to CAD $100,000/day |
Accessibility lawsuits in the US hit record numbers in 2024. Major players like Domino’s and Netflix faced action, but thousands of smaller companies were targeted too.
Meanwhile, the European Accessibility Act (EAA) is now active. It affects any company selling to EU customers, regardless of where you’re based.
Key takeaway: Retrofitting is 10x more expensive than building it right. You can’t afford to wait.
The Overlay Widget Trap: Accessibility overlay widgets (the “accessibility toolbar” popups) don’t work. The National Federation of the Blind has formally opposed them. They don’t fix underlying code issues, they often break screen reader functionality, and they’ve been named in lawsuits as insufficient remediation. There’s no shortcut here.
What is WCAG? A Design Engineer’s Reference
WCAG (Web Content Accessibility Guidelines) is the W3C standard for web accessibility.
It’s organized into three conformance levels:
| Level | What It Means | When to Target |
|---|---|---|
| A | Minimum accessibility, removes absolute barriers | Never target A alone |
| AA | Standard compliance, covers most user needs | Default target for all projects |
| AAA | Enhanced accessibility, maximum inclusion | Specialized apps, government |
The current version is WCAG 2.2 (October 2023).
WCAG 3.0 is in development and will replace pass/fail with a scoring model, but it’s years away from adoption.
The POUR Principles: A Quick Reference
WCAG organizes requirements around four principles. Here’s what each means for your component work:
| Principle | Core Question | Design Engineer Actions |
|---|---|---|
| Perceivable | Can users perceive the content? | Alt text, captions, 4.5:1 contrast, don’t rely on color alone |
| Operable | Can users operate the interface? | Keyboard access, no seizure triggers, skip links, touch targets ≥24px |
| Understandable | Can users understand the content? | Clear labels, predictable navigation, error identification |
| Robust | Does it work with assistive tech? | Valid HTML, proper ARIA, tested with screen readers |
WCAG 2.2: What’s New
WCAG 2.2 added 9 new success criteria. Here’s what matters for design engineers:
Level A (Minimum):
- 3.2.6 Consistent Help: Help mechanisms must appear in the same relative location across pages
- 3.3.7 Redundant Entry: Don’t make users re-enter information already provided in the same session
- 3.3.8 Accessible Authentication (Minimum): Don’t require cognitive function tests (like puzzles) for authentication
Level AA (Standard - Target This):
- 2.4.11 Focus Not Obscured (Minimum): Focused elements must not be entirely hidden by author-created content (sticky headers, modals)
- 2.4.12 Focus Not Obscured (Enhanced): Focused elements should be fully visible (no partial obscuring)
- 2.5.7 Dragging Movements: Drag-and-drop must have a single-pointer alternative (buttons, keyboard)
- 2.5.8 Target Size (Minimum): Interactive targets must be at least 24×24 CSS pixels
- 3.3.9 Accessible Authentication (Enhanced): Authentication must not require object recognition or personal content
Level AAA (Enhanced):
- 2.4.13 Focus Appearance: Focus indicators must meet specific size and contrast requirements (2px perimeter, 3:1 contrast)
What This Means for Your Next Sprint:
- Audit sticky headers and floating elements for focus obscuring
- Add keyboard alternatives to all drag-and-drop interactions
- Increase touch targets to 24px minimum (44px recommended)
- Remove CAPTCHA-style authentication challenges
- Ensure focus indicators are visible and meet contrast requirements
Design System Accessibility: Tokens and Figma Workflow
Accessibility starts in Figma, not in code.
If your design tokens don’t encode accessibility constraints, you’re setting up your implementation for failure.
How Do You Audit Design Tokens for Accessibility?
Your color token system should guarantee WCAG compliance by construction. Here’s the pattern I use:
// tokens/colors.ts
export const colors = {
// Semantic tokens with built-in contrast guarantees
text: {
primary: '#1a1a1a', // 16.1:1 on white
secondary: '#525252', // 7.2:1 on white
tertiary: '#737373', // 4.6:1 on white (minimum for AA)
inverse: '#ffffff', // Use on dark backgrounds only
},
background: {
primary: '#ffffff',
secondary: '#f5f5f5', // 1.1:1 - decorative only
elevated: '#ffffff',
},
interactive: {
primary: '#0066cc', // 4.5:1 on white
primaryHover: '#0052a3', // 5.8:1 on white
focus: '#0066cc', // Used for focus rings
},
status: {
error: '#c41e3a', // 5.9:1 on white
errorBackground: '#fef2f2',
success: '#15803d', // 4.8:1 on white
successBackground: '#f0fdf4',
},
} as const;
// Document contrast ratios in your token definitions
export const contrastRatios = {
'text.primary/background.primary': 16.1,
'text.secondary/background.primary': 7.2,
'text.tertiary/background.primary': 4.6,
'interactive.primary/background.primary': 4.5,
} as const;
Contrast Ratio Visual Reference
Understanding contrast ratios is easier with visual examples:

WCAG contrast requirements: 4.5:1 for normal text, 3:1 for large text and UI components. Use contrast checkers to verify your color combinations meet accessibility standards.
Pro Tip: Build contrast checking into your design token workflow. If a token doesn’t meet 4.5:1 on its intended background, document it as “decorative only” or adjust the color.
Figma Accessibility Plugins Every Design Engineer Needs
| Plugin | Purpose | When to Use | Cost |
|---|---|---|---|
| Stark | Contrast checker, vision simulator, WCAG compliance | Every color decision | Free tier available |
| A11y Annotation Kit | Document a11y specs for handoff | Before dev handoff | Free |
| Able | Contrast checker (free alternative) | Budget-conscious teams | Free |
| Color Blind | Simulate color blindness types | Validating color-only indicators | Free |
| WAVE | Accessibility evaluation | Final design review | Free |
Design Engineer Tip: Create a Figma component called “A11y Annotation” that documents: focus order, keyboard interactions, ARIA roles, and screen reader announcements. Add it to every interactive component in your design system. This eliminates ambiguity during implementation.
Reduced Motion in Your Token System
Motion preferences should be design tokens, not afterthoughts:
// tokens/motion.ts
export const motion = {
duration: {
instant: '0ms',
fast: '150ms',
normal: '300ms',
slow: '500ms',
},
easing: {
default: 'cubic-bezier(0.4, 0, 0.2, 1)',
in: 'cubic-bezier(0.4, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.2, 1)',
},
} as const;
// In CSS, always wrap animations
// @media (prefers-reduced-motion: no-preference) { ... }
/* Base: no motion */
.card {
opacity: 1;
transform: translateY(0);
}
/* Progressive enhancement: add motion only if preferred */
@media (prefers-reduced-motion: no-preference) {
.card {
transition:
opacity var(--duration-normal) var(--easing-default),
transform var(--duration-normal) var(--easing-default);
}
.card[data-entering] {
opacity: 0;
transform: translateY(8px);
}
}
SVG Icon Accessibility: Getting Icons Right
SVG icons are everywhere in modern interfaces, but most teams implement them incorrectly for accessibility. The key question: is the icon decorative or informative?
How Do You Make SVG Icons Accessible?
Decision Framework:
- Decorative icon (next to visible text): Hide from screen readers
- Informative icon (icon-only button): Provide accessible name on the button
- Standalone informative SVG (charts, diagrams): Use
<title>and<desc>inside SVG
Decorative Icons (Icon + Text)
When an icon appears next to visible text, the icon is decorative. Hide it from screen readers to avoid redundant announcements.
// ✅ Correct: Icon is decorative, text provides the label
<button>
<svg aria-hidden="true" focusable="false" width="16" height="16">
<path d="M5 13l4-4-4-4" />
</svg>
Save Changes
</button>
// ❌ Wrong: Screen reader announces "Save icon Save Changes"
<button>
<svg role="img" aria-label="Save icon">
<path d="..." />
</svg>
Save Changes
</button>
Why focusable="false"? Internet Explorer makes SVGs focusable by default, breaking keyboard navigation. Always add this attribute.
Informative Icons (Icon-Only Buttons)
Icon-only buttons need an accessible name. Provide it on the button, not the SVG.
// ✅ Correct: Accessible name on button, SVG hidden
<button aria-label="Save changes">
<svg aria-hidden="true" focusable="false">
<path d="..." />
</svg>
</button>
// ✅ Also correct: Visually hidden text
<button>
<svg aria-hidden="true" focusable="false">
<path d="..." />
</svg>
<span className="sr-only">Save changes</span>
</button>
// ❌ Wrong: aria-label on SVG doesn't work reliably
<button>
<svg aria-label="Save changes">
<path d="..." />
</svg>
</button>
Standalone Informative SVGs (Charts, Diagrams)
When an SVG conveys information (not just decoration), use <title> and <desc> inside the SVG.
// ✅ Correct: SVG as content with title and description
<svg role="img" aria-labelledby="chart-title chart-desc" width="400" height="300">
<title id="chart-title">Q4 2025 Sales Data</title>
<desc id="chart-desc">
Bar chart showing 40% increase in sales from Q3 to Q4,
with revenue growing from $2M to $2.8M
</desc>
<!-- chart content -->
</svg>
// For complex data visualizations, also provide a data table
<details>
<summary>View data table</summary>
<table>
<caption>Q4 2025 Sales Data</caption>
<!-- accessible table markup -->
</table>
</details>
Icon Libraries with Built-in Accessibility
| Library | Accessibility | Notes |
|---|---|---|
| Heroicons | ✅ Excellent | Includes aria-hidden="true" by default |
| Lucide | ✅ Excellent | React components with proper ARIA |
| Phosphor Icons | ✅ Good | Accessible SVG sprites |
| Font Awesome | ⚠️ Mixed | Requires manual aria-hidden on decorative icons |
| Material Icons | ⚠️ Mixed | Font-based icons need extra care |
Recommendation: Use Heroicons or Lucide for React projects. They handle accessibility correctly out of the box.
Common SVG Accessibility Mistakes
| Mistake | Problem | Fix |
|---|---|---|
No aria-hidden on decorative icons | Screen reader announces “image” or SVG content | Add aria-hidden="true" |
aria-label on SVG instead of button | Inconsistent screen reader support | Put aria-label on the button |
Missing focusable="false" | SVG becomes focusable in IE | Always add focusable="false" |
Using <title> for decorative icons | Screen reader announces title | Use aria-hidden="true" instead |
| Complex SVG without text alternative | Chart data inaccessible | Provide data table or detailed description |
Tooltip Accessibility: The Tricky Pattern
Tooltips are one of the most commonly implemented incorrectly accessible patterns. They require both mouse and keyboard support, and many implementations fail basic accessibility requirements.
What Makes Tooltips Accessible?
A tooltip is a non-modal overlay containing text-only content that provides supplemental information about an existing UI control. It’s hidden by default and appears on hover or focus.
Key Requirements:
- Trigger must be focusable (button, link, or
tabindex="0") - Tooltip appears on both hover AND focus
- Tooltip closes with Escape key
- Tooltip closes when focus moves away
- Use
role="tooltip"on the tooltip container - Connect with
aria-describedbyon the trigger
Accessible Tooltip Implementation
import { useState, useId } from 'react';
function AccessibleTooltip({ trigger, content }) {
const [isOpen, setIsOpen] = useState(false);
const tooltipId = useId();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
return (
<>
<button
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
className="tooltip-trigger"
>
{trigger}
</button>
{isOpen && (
<div
id={tooltipId}
role="tooltip"
className="tooltip"
>
{content}
</div>
)}
</>
);
}
// Usage
<AccessibleTooltip
trigger={<InfoIcon />}
content="This field is required for account verification"
/>
Tooltip vs Popover: When to Use Which
| Feature | Tooltip | Popover |
|---|---|---|
| Content | Text only | Can include links, buttons, forms |
| Trigger | Hover or focus | Click or tap |
| Dismissal | Automatic (on blur/mouseout) | Manual (close button or click outside) |
| Keyboard | Escape to close | Escape to close, Tab to navigate inside |
| ARIA Role | role="tooltip" | role="dialog" or no role |
| Use Case | Supplemental info (e.g., “Save changes”) | Interactive content (e.g., user profile card) |
Rule of Thumb: If it contains interactive elements (links, buttons), it’s not a tooltip - it’s a popover or dialog.
Common Tooltip Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Hover-only tooltip | Keyboard users can’t access | Add onFocus and onBlur handlers |
| Links inside tooltip | Tooltip closes before link is clickable | Use a popover instead |
Using aria-label instead of aria-describedby | Replaces button’s accessible name | Use aria-describedby for supplemental info |
| No Escape key handler | Keyboard users can’t dismiss | Add onKeyDown handler |
| Tooltip on non-focusable element | Keyboard users never see it | Add tabindex="0" or use a button |
Tooltip Libraries with Good Accessibility
- Radix UI Tooltip: Fully accessible, handles all edge cases
- React Aria useTooltip: Adobe’s accessible tooltip hook
- Floating UI: Positioning library with accessibility support
Recommendation: Don’t build tooltips from scratch. Use Radix UI Tooltip - it handles all the accessibility requirements correctly.
Semantic HTML vs ARIA: When to Use Which
This is where most accessibility bugs originate. Engineers reach for ARIA when semantic HTML would work better, or they use the wrong ARIA pattern entirely.
The Decision Framework

Decision flowchart: Use native HTML whenever possible. Only use ARIA for custom widgets without HTML equivalents. For static content, semantic HTML with proper labeling is sufficient.
What is the Accessibility Tree?
The accessibility tree is a parallel structure to the DOM that browsers expose to assistive technologies. It contains only semantically meaningful nodes with their:
- Role: What the element is (button, link, heading)
- Name: The accessible label (button text, aria-label, alt text)
- State: Current status (expanded, checked, disabled)
- Value: Current value for inputs
Screen readers navigate the accessibility tree, not the visual DOM. This is why a <div onclick="..."> is invisible to screen readers while <button> works automatically.
<!-- DOM -->
<div class="card">
<div class="card-image">
<img src="product.jpg" alt="Blue running shoes, side view">
</div>
<div class="card-content">
<h3>Running Shoes</h3>
<p>Lightweight and breathable</p>
<button>Add to cart</button>
</div>
</div>
<!-- Accessibility Tree (simplified) -->
<!--
- img: "Blue running shoes, side view"
- heading level 3: "Running Shoes"
- text: "Lightweight and breathable"
- button: "Add to cart"
-->

The browser filters the DOM, removing generic divs and keeping only semantic elements for the accessibility tree that screen readers navigate.
How to View the Accessibility Tree in DevTools
Understanding the accessibility tree is critical for debugging. Here’s how to inspect it in different browsers:
Chrome/Edge:
- Open DevTools (F12)
- Go to Elements tab
- Select an element in the DOM
- Click Accessibility tab in the right panel
- See the element’s accessible name, role, and computed properties
Pro Tip: Enable full accessibility tree view:
- DevTools Settings (⚙️) > Experiments
- Check “Enable full accessibility tree view in Elements pane”
- Restart DevTools
- An accessibility icon appears in the Elements tab - click it to see the full tree
Firefox:
- Open DevTools (F12)
- Go to Accessibility tab (top toolbar)
- Enable “Check for issues”
- Expand the document node to see the full tree
- Firefox highlights accessibility issues automatically
What to Look For:
- ✅ Buttons have accessible names (not “button” or empty)
- ✅ Form inputs have labels (not just placeholders)
- ✅ Images have alt text or are marked decorative
- ✅ Headings are in logical order (h1 → h2 → h3)
- ❌ Generic roles (div, span) in interactive areas
- ❌ Missing accessible names on focusable elements
- ❌ Incorrect ARIA roles or states
Example: Debugging a Button
// Problem: Button has no accessible name
<button onClick={handleSave}>
<SaveIcon />
</button>
// DevTools Accessibility tab shows:
// Name: "" (empty)
// Role: button
// ❌ This will be announced as "button" with no context
// Fix: Add accessible name
<button onClick={handleSave} aria-label="Save changes">
<SaveIcon aria-hidden="true" />
</button>
// DevTools now shows:
// Name: "Save changes"
// Role: button
// ✅ Announced as "Save changes, button"
How Browsers Calculate Accessible Names
When a screen reader focuses an element, it announces the element’s “accessible name.” Browsers calculate this using a specific algorithm defined by the W3C.
Priority Order (Highest to Lowest):
aria-labelledby- References another element’s textaria-label- Provides a string label- Native label association -
<label for="...">for form inputs - Element content - Button text, link text, heading text
titleattribute - Last resort, inconsistent supportplaceholder- Not reliable, don’t use for labels
Examples:
<!-- Name: "Submit form" (aria-label wins over content) -->
<button aria-label="Submit form" title="Click to submit">
Send
</button>
<!-- Name: "Email address" (label wins over placeholder) -->
<label for="email">Email address</label>
<input id="email" placeholder="you@example.com" />
<!-- Name: "Settings" (aria-labelledby wins over everything) -->
<h2 id="dialog-title">Settings</h2>
<dialog aria-labelledby="dialog-title">
<p>Configure your preferences</p>
</dialog>
<!-- Name: "Close" (element content) -->
<button>Close</button>
<!-- Name: "" (empty - BAD!) -->
<button>
<svg><path d="..." /></svg>
</button>
Common Mistakes:
| Mistake | Problem | Fix |
|---|---|---|
Using placeholder as label | Disappears when typing, not announced reliably | Use <label> element |
Using title for critical info | Inconsistent screen reader support | Use aria-label or visible text |
| Redundant labels | <button aria-label="Close">Close</button> | Remove aria-label, use button text |
| Empty accessible name | Icon-only button with no label | Add aria-label or visually hidden text |
aria-label on non-interactive element | Ignored by screen readers | Use semantic HTML or add role |
How to Check: Open Chrome DevTools > Elements > Select element > Accessibility tab > “Computed Properties” > “Name”
Common ARIA Mistakes
| Mistake | Problem | Fix |
|---|---|---|
<div role="button"> | No keyboard support, no form submission | Use <button> |
aria-label on <div> | Divs aren’t interactive, label is ignored | Add a role or use semantic element |
role="link" without href | Not keyboard focusable | Use <a href="..."> |
aria-hidden="true" on focusable element | Creates focus trap | Remove from tab order first with tabindex="-1" |
Redundant role="button" on <button> | Unnecessary, clutters code | Remove the role |
aria-label vs aria-labelledby confusion | Wrong attribute for the use case | See quick reference below |
ARIA Quick Reference: aria-label vs aria-labelledby vs aria-describedby
| Attribute | When to Use | Example |
|---|---|---|
| aria-label | Provide a label when no visible text exists | <button aria-label="Close dialog"><X /></button> |
| aria-labelledby | Reference an existing element’s text as the label | <dialog aria-labelledby="dialog-title"><h2 id="dialog-title">Settings</h2></dialog> |
| aria-describedby | Add supplementary description (not the primary label) | <input aria-describedby="password-hint" /><span id="password-hint">Must be 8+ characters</span> |
Rule of thumb: Use semantic HTML first, aria-labelledby to reference existing text, aria-label only when no visible text exists, and aria-describedby for additional context.

Quick reference for choosing between aria-label, aria-labelledby, and aria-describedby. Use aria-label for icon buttons, aria-labelledby to reference existing text, and aria-describedby for supplemental information.
When ARIA is Actually Necessary
<!-- 1. Icon buttons need accessible names -->
<button aria-label="Close dialog">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<!-- 2. Multiple landmarks need distinction -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>
<!-- 3. Dynamic content needs live regions -->
<div aria-live="polite" aria-atomic="true">
<span>3 items in cart</span>
</div>
<!-- 4. Custom widgets need full ARIA patterns -->
<div role="tablist" aria-label="Product information">
<button role="tab" aria-selected="true" aria-controls="panel-1">
Description
</button>
<button role="tab" aria-selected="false" aria-controls="panel-2">
Reviews
</button>
</div>
<div role="tabpanel" id="panel-1">...</div>
<div role="tabpanel" id="panel-2" hidden>...</div>
Keyboard Navigation Patterns for Custom Components
Every interactive element must be keyboard accessible. Native HTML elements handle this automatically. Custom components require manual implementation.
The Keyboard Contract
| Key | Expected Behavior |
|---|---|
| Tab | Move to next focusable element |
| Shift+Tab | Move to previous focusable element |
| Enter | Activate buttons, submit forms, follow links |
| Space | Activate buttons, toggle checkboxes |
| Arrow keys | Navigate within composite widgets |
| Escape | Close modals, cancel operations |
| Home/End | Jump to first/last item in lists |
What is Roving Tabindex?
Roving tabindex is a keyboard pattern for composite widgets (tabs, menus, toolbars) where only one item is in the tab order at a time. Arrow keys move focus within the widget.
I use this pattern constantly when building interactive UI components. The key insight is that composite widgets should behave like a single tab stop, with arrow keys handling internal navigation.
function TabList({ tabs, activeTab, onTabChange }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (event: KeyboardEvent, index: number) => {
let newIndex = index;
switch (event.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
setFocusedIndex(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => (tabRefs.current[index] = el)}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={focusedIndex === index ? 0 : -1}
onClick={() => onTabChange(tab.id)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
);
}
How Do You Trap Focus in a Modal?
Focus trapping ensures keyboard users can’t Tab outside an open modal. This requires:
- On open: Store previous focus, move focus into modal
- While open: Cycle Tab between first and last focusable elements
- On close: Return focus to the trigger element

Focus trap creates a circular navigation pattern within the modal. Tabbing from the last element returns to the first, and Shift+Tab from the first goes to the last, effectively “trapping” keyboard focus inside the modal until it’s closed.
This is one of those patterns where understanding React hooks deeply pays off. The useEffect cleanup is critical for removing event listeners.
function useModalFocusTrap(isOpen: boolean, onClose: () => void) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
// Focus first focusable element or the modal itself
const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable?.length) {
focusable[0].focus();
} else {
modalRef.current?.focus();
}
} else if (previousFocus.current) {
previousFocus.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
return modalRef;
}
// Usage
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useModalFocusTrap(isOpen, onClose);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Focus Not Obscured: WCAG 2.2’s Trickiest New Criterion
Success Criterion 2.4.11 (Focus Not Obscured - Minimum, Level AA) requires that when a UI component receives keyboard focus, it’s not entirely hidden by author-created content. This trips up teams using sticky headers, cookie banners, and floating action buttons.

Without scroll-padding, focused elements can be hidden behind sticky headers (fail). With scroll-padding, the browser automatically scrolls elements into the visible area (pass).
// ❌ Problem: Sticky header obscures focused elements
function Layout({ children }) {
return (
<>
<header className="fixed top-0 z-50 h-16 bg-white">
<nav>...</nav>
</header>
<main className="pt-16">
{children} {/* First focusable element hidden under header */}
</main>
</>
);
}
// ✅ Solution: Scroll padding accounts for fixed header
function Layout({ children }) {
return (
<>
<header className="fixed top-0 z-50 h-16 bg-white">
<nav>...</nav>
</header>
<main className="pt-16" style={{ scrollPaddingTop: '80px' }}>
{children}
</main>
</>
);
}
/* Global fix for sticky headers */
html {
scroll-padding-top: 80px; /* Height of sticky header + buffer */
}
/* For cookie banners at bottom */
html {
scroll-padding-bottom: 100px;
}
/* Ensure focus indicators aren't clipped */
*:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
scroll-margin: 80px; /* Ensures element scrolls into view with buffer */
}
Testing for Focus Not Obscured:
- Tab through your entire page with a sticky header visible
- Check that every focused element is at least partially visible
- Test with cookie banners, chat widgets, and floating CTAs active
- Use browser DevTools to simulate different viewport heights
Drag-and-Drop Accessibility: WCAG 2.5.7
Success Criterion 2.5.7 (Dragging Movements, Level AA) requires that any functionality that uses dragging has a single-pointer alternative. This affects sortable lists, kanban boards, sliders, and file uploads.
// ❌ Problem: Drag-only reordering
function SortableList({ items, onReorder }) {
return (
<DragDropContext onDragEnd={onReorder}>
<Droppable droppableId="list">
{(provided) => (
<ul ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<li ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
{item.name}
</li>
)}
</Draggable>
))}
</ul>
)}
</Droppable>
</DragDropContext>
);
}
// ✅ Solution: Drag + keyboard alternative
function AccessibleSortableList({ items, onReorder }) {
const moveItem = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= items.length) return;
const newItems = [...items];
[newItems[index], newItems[newIndex]] = [newItems[newIndex], newItems[index]];
onReorder(newItems);
};
return (
<ul role="list" aria-label="Sortable task list">
{items.map((item, index) => (
<li key={item.id} className="sortable-item">
<span>{item.name}</span>
<div className="sort-controls" role="group" aria-label={`Reorder ${item.name}`}>
<button
onClick={() => moveItem(index, 'up')}
disabled={index === 0}
aria-label={`Move ${item.name} up`}
>
↑
</button>
<button
onClick={() => moveItem(index, 'down')}
disabled={index === items.length - 1}
aria-label={`Move ${item.name} down`}
>
↓
</button>
</div>
</li>
))}
</ul>
);
}
Accessible File Upload (Drag + Click Alternative):
function AccessibleFileUpload({ onUpload }) {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
onUpload(files);
};
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
onUpload(files);
};
return (
<div
className={`upload-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="sr-only"
id="file-upload"
/>
<label htmlFor="file-upload" className="upload-label">
<UploadIcon aria-hidden="true" />
<span>Drag and drop files here, or click to select</span>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="upload-button"
>
Choose Files
</button>
</label>
</div>
);
}
Accessible Range Slider (Drag + Keyboard):
// Native input[type="range"] is accessible by default
function AccessibleSlider({ value, onChange, min = 0, max = 100, label }) {
return (
<div className="slider-container">
<label htmlFor="slider" className="slider-label">
{label}: {value}
</label>
<input
id="slider"
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-label={label}
/>
</div>
);
}
The inert Attribute: Modern Focus Management
What is the inert attribute?
The inert attribute is a global HTML attribute (added in 2023) that makes an element and all its descendants non-interactive.
It’s like combining aria-hidden="true" + tabindex="-1" + pointer-events: none into a single attribute.
When an element is inert:
- Cannot receive focus (keyboard or programmatic)
- Cannot be clicked or tapped
- Excluded from the accessibility tree
- Excluded from find-in-page searches
- All descendants are also inert
Browser Support: Chrome 102+, Firefox 112+, Safari 15.5+ (95%+ global support as of 2026)
When to Use inert
1. Modal Dialogs - Mark background content as inert while modal is open
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
const appRoot = document.getElementById('app-root');
if (isOpen) {
appRoot?.setAttribute('inert', '');
} else {
appRoot?.removeAttribute('inert');
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Settings</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
2. Multi-Step Forms - Mark inactive steps as inert
function MultiStepForm({ currentStep }) {
return (
<div>
<div inert={currentStep !== 1 ? '' : undefined}>
<Step1 />
</div>
<div inert={currentStep !== 2 ? '' : undefined}>
<Step2 />
</div>
<div inert={currentStep !== 3 ? '' : undefined}>
<Step3 />
</div>
</div>
);
}
3. Collapsed Accordions - Mark hidden content as inert
function Accordion({ isOpen, children }) {
return (
<div>
<button onClick={toggle} aria-expanded={isOpen}>
Toggle
</button>
<div hidden={!isOpen} inert={!isOpen ? '' : undefined}>
{children}
</div>
</div>
);
}
inert vs Manual Focus Trapping
| Approach | Pros | Cons |
|---|---|---|
inert attribute | Simple, browser-native, prevents mouse clicks, no JS needed | Requires polyfill for older browsers |
| Manual focus trap | Works in all browsers, fine-grained control | Complex code, easy to get wrong, doesn’t prevent mouse clicks |
Recommendation: Use inert for modern browsers with a polyfill fallback.
Polyfill for Older Browsers
npm install wicg-inert
import 'wicg-inert';
// Now inert works in all browsers
<div inert={isModalOpen ? '' : undefined}>
Background content
</div>
Why inert is Better Than aria-hidden:
// ❌ aria-hidden alone doesn't prevent interaction
<div aria-hidden="true">
<button onClick={handleClick}>
Still clickable with mouse!
</button>
</div>
// ✅ inert prevents all interaction
<div inert="">
<button onClick={handleClick}>
Cannot click, focus, or interact
</button>
</div>
Common Use Cases:
- Modal dialogs (mark background as inert)
- Slide-out panels (mark main content as inert)
- Loading states (mark form as inert while submitting)
- Disabled form sections (mark section as inert)
- Off-canvas navigation (mark main content as inert when menu is open)
Skip Links: The Most Forgotten Pattern
Skip links let keyboard users bypass repetitive navigation. They should be the first focusable element on every page.
<!-- First element in <body> -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- Later in the page -->
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" allows programmatic focus -->
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: var(--color-interactive-primary);
color: white;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
Screen Reader Testing: A Practical Workflow
Automated tools catch 30-40% of accessibility issues. The rest require manual testing with actual assistive technology.
Which Screen Reader Should You Test With?
| Screen Reader | Platform | Cost | Market Share | Best For |
|---|---|---|---|---|
| NVDA | Windows | Free | ~40% | Primary Windows testing |
| JAWS | Windows | $1000+/year | ~30% | Enterprise compliance |
| VoiceOver | macOS/iOS | Built-in | ~15% | Mac developers |
| TalkBack | Android | Built-in | ~10% | Mobile testing |
Recommendation: Test with VoiceOver (if you’re on Mac) and NVDA (install on a Windows VM or machine). These two cover 55%+ of screen reader users and are free.
VoiceOver Quick Reference (macOS)
| Action | Shortcut |
|---|---|
| Toggle VoiceOver | Cmd + F5 |
| Navigate next | Ctrl + Option + Right Arrow |
| Navigate previous | Ctrl + Option + Left Arrow |
| Activate element | Ctrl + Option + Space |
| Read all | Ctrl + Option + A |
| Open Rotor | Ctrl + Option + U |
| Stop speaking | Ctrl |
NVDA Quick Reference (Windows)
| Action | Shortcut |
|---|---|
| Start NVDA | Desktop shortcut or Ctrl + Alt + N |
| Navigate (browse mode) | Arrow keys |
| Activate element | Enter |
| Tab through forms | Tab |
| Elements list | NVDA + F7 |
| Stop speaking | Ctrl |
| Next heading | H |
| Next landmark | D |
The Screen Reader Testing Checklist
Run through this for every component:
Structure
- Page has exactly one
<h1> - Headings are sequential (h1 → h2 → h3, no skipping)
- Landmarks are present and labeled (
<main>,<nav aria-label="...">) - Skip link works and focuses main content
Interactive Elements
- All buttons announce their purpose
- Links announce their destination
- Form inputs announce their labels
- Required fields are announced as required
- Error messages are announced when they appear
Dynamic Content
- Loading states are announced
- Toast notifications are announced
- Form submission results are announced
- Content changes in SPAs are announced
If you’re working with React Server Components, note that server-rendered content is generally more accessible by default since it doesn’t rely on client-side hydration for initial content.
Images and Media
- Informative images have descriptive alt text
- Decorative images have
alt="" - Complex images have extended descriptions
- Videos have captions
Accessible Component Libraries: Don’t Reinvent the Wheel
Building accessible custom components from scratch is hard.
I spent two weeks building a custom combobox for a project last year before realizing I was reinventing what React Aria already does perfectly.
These libraries handle keyboard navigation, focus management, and ARIA for you:
Which Accessible Component Library Should You Use?
| Library | Framework | Styling | Components | Best For |
|---|---|---|---|---|
| Radix UI Primitives | React | Unstyled | 30+ | Design systems, full control |
| Base UI | React | Unstyled | 20+ | Next-gen Radix (2025), fresh codebase |
| React Aria | React | Unstyled | 40+ | Complex widgets, i18n (30+ languages) |
| Headless UI | React, Vue | Unstyled | 15+ | Tailwind projects |
| Ark UI | React, Vue, Solid | Unstyled | 35+ | Multi-framework teams |
| shadcn/ui | React | Copy/paste | 50+ | Built on Radix, highly customizable |
If you’re evaluating React vs other frameworks for your next project, accessibility support in the ecosystem is a factor worth considering. I cover this in my Angular vs React comparison.
Design Engineer Tip: I use Radix Primitives for every design system I build. The components are unstyled, fully accessible, and composable. You get focus management, keyboard navigation, and ARIA handled correctly, then apply your design tokens on top. For 2026 projects, also consider Base UI - the next evolution from the Radix team with lessons learned from years of production use. If you need internationalization (30+ languages, 13 calendar systems), React Aria is unmatched. For rapid prototyping with Tailwind, shadcn/ui (built on Radix) offers copy-paste components that are production-ready.
Example: Accessible Dropdown with Radix
Compare building a dropdown from scratch (100+ lines, easy to get wrong) vs using Radix:
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
function UserMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button aria-label="User menu">
<Avatar />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="dropdown-content" sideOffset={5}>
<DropdownMenu.Item className="dropdown-item">
Profile
</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item">
Settings
</DropdownMenu.Item>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item" color="red">
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
This gives you:
- Keyboard navigation (arrow keys, Home, End)
- Focus management (focus returns to trigger on close)
- Proper ARIA roles and attributes
- Click outside to close
- Escape to close
- Typeahead search
All without writing a single line of accessibility code.
shadcn/ui: Copy-Paste Accessible Components
shadcn/ui has exploded in popularity in 2025-2026.
It’s not a traditional component library - it’s a collection of copy-paste components built on Radix Primitives and styled with Tailwind CSS.
You copy the code directly into your project, giving you full ownership and customization.
Why developers love it:
- Built on Radix (fully accessible by default)
- Copy-paste, not npm install (no dependency bloat)
- Tailwind styling (easy to customize)
- 50+ production-ready components
- TypeScript support
- Works with Next.js, Remix, Astro, Vite
When to use shadcn/ui vs Radix directly:
- Use shadcn/ui if you want pre-styled components you can customize
- Use Radix Primitives if you’re building a design system from scratch
- Use Base UI if you want the next-gen Radix with a fresh codebase
- Use React Aria if you need internationalization (30+ languages)
Accessibility in Your Development Workflow
Catching accessibility issues while typing is faster than catching them in code review or production. Here’s how to build accessibility into your development workflow.
eslint-plugin-jsx-a11y: Accessibility Linting for React
eslint-plugin-jsx-a11y is the most immediate feedback loop for React developers. It catches common accessibility mistakes as you type - before the code even reaches the browser.
Installation:
npm install eslint-plugin-jsx-a11y --save-dev
Configuration (.eslintrc.json):
{
"extends": [
"plugin:jsx-a11y/recommended"
],
"plugins": [
"jsx-a11y"
]
}
What It Catches:
| Rule | What It Prevents | Example |
|---|---|---|
alt-text | Images without alt text | <img src="..." /> ❌ |
click-events-have-key-events | Click handlers on divs | <div onClick={...} /> ❌ |
no-noninteractive-element-interactions | Click handlers on <p>, <div> | <p onClick={...} /> ❌ |
label-has-associated-control | Labels without inputs | <label>Email</label> ❌ |
aria-role | Invalid ARIA roles | <div role="invalid" /> ❌ |
aria-props | Invalid ARIA attributes | <div aria-fake="true" /> ❌ |
no-autofocus | Autofocus on page load | <input autoFocus /> ⚠️ |
Real-World Example:
// ❌ ESLint error: "Visible, non-interactive elements with click handlers must have at least one keyboard listener"
<div onClick={handleClick}>
Click me
</div>
// ✅ Fixed: Use a button
<button onClick={handleClick}>
Click me
</button>
// ❌ ESLint error: "img elements must have an alt prop"
<img src="/avatar.jpg" />
// ✅ Fixed: Add alt text
<img src="/avatar.jpg" alt="User profile photo" />
// ❌ ESLint error: "Form label must have associated control"
<label>Email</label>
<input type="email" />
// ✅ Fixed: Associate label with input
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Why This Matters:
- Immediate feedback: Errors appear in your editor as you type
- Prevents bad patterns: Catches issues before code review
- Educational: Error messages explain why something is inaccessible
- Zero runtime cost: Linting happens at build time
Recommended Rules:
{
"rules": {
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/no-noninteractive-element-interactions": "error",
"jsx-a11y/no-static-element-interactions": "error"
}
}
Integration with CI/CD:
# .github/workflows/ci.yml
name: Accessibility Checks
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run lint # Includes jsx-a11y rules
The Complete Accessibility Testing Stack
| Tool | What It Catches | When It Runs | Cost |
|---|---|---|---|
| eslint-plugin-jsx-a11y | React-specific issues (missing alt, click on div) | While typing | Free |
| axe DevTools | 30-40% of WCAG issues | Manual browser testing | Free |
| Pa11y CI | Automated WCAG checks | CI/CD pipeline | Free |
| Lighthouse | Core Web Vitals + accessibility score | CI/CD or manual | Free |
| VoiceOver/NVDA | Real screen reader experience | Manual testing | Free |
Recommendation: Use all five. eslint-plugin-jsx-a11y catches issues while coding. axe DevTools catches issues in the browser. Pa11y catches issues in CI. Lighthouse provides a score. Screen readers catch the remaining 60-70% that automated tools miss.
Animation Accessibility: Respecting Motion Preferences
About 35% of adults over 40 have experienced vestibular dysfunction.
Animations that seem delightful to you can cause dizziness, nausea, or seizures for others.
What Animations Should You Avoid?
| High Risk (Avoid or Gate) | Lower Risk (Usually Safe) |
|---|---|
| Parallax scrolling | Opacity fades |
| Large-scale zooming | Color transitions |
| Spinning/rotating elements | Subtle hover states |
| Auto-playing carousels | User-triggered animations |
| Background video | Border/shadow changes |
| Infinite animations | Single-play micro-interactions |
| Flashing (>3 times/second) | Transform: translate (small) |
The prefers-reduced-motion Pattern
Always check for motion preferences. The safest pattern is opt-in animation:
/* Default: no animation */
.element {
opacity: 1;
transform: translateY(0);
}
/* Only animate if user hasn't requested reduced motion */
@media (prefers-reduced-motion: no-preference) {
.element {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.element[data-state="entering"] {
opacity: 0;
transform: translateY(8px);
}
}
Reduced Motion in Framer Motion
If you’re using Framer Motion, use the useReducedMotion hook:
import { motion, useReducedMotion } from 'framer-motion';
function AnimatedCard({ children }) {
const shouldReduceMotion = useReducedMotion();
const variants = {
hidden: {
opacity: 0,
y: shouldReduceMotion ? 0 : 20
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: shouldReduceMotion ? 0.01 : 0.3,
}
},
};
return (
<motion.div
initial="hidden"
animate="visible"
variants={variants}
>
{children}
</motion.div>
);
}
Accessible Loading States
Loading spinners are essential but can be problematic. Provide alternatives:
function LoadingIndicator() {
const shouldReduceMotion = useReducedMotion();
return (
<div role="status" aria-label="Loading">
{shouldReduceMotion ? (
<span className="loading-text">Loading...</span>
) : (
<svg className="spinner" viewBox="0 0 50 50" aria-hidden="true">
<circle
cx="25"
cy="25"
r="20"
fill="none"
strokeWidth="4"
/>
</svg>
)}
</div>
);
}
Accessibility in Your CI/CD Pipeline
Catching accessibility issues before they reach production requires automated testing in your pipeline.
This is part of the broader React performance optimization mindset: catch problems early, automate what you can, and measure continuously.
Automated Testing Tools
| Tool | Type | Catches | Best For |
|---|---|---|---|
| axe DevTools | Browser extension | ~30-40% of issues | Manual testing, instant feedback |
| axe-core | Library | ~30-40% of issues | Integration into any test runner |
| WAVE | Browser extension | ~30-40% of issues | Visual feedback, free |
| Accessibility Insights | Browser extension | ~30-40% of issues | Microsoft tool, guided assessments |
| Pa11y | CLI | ~30-40% of issues | CI/CD pipelines |
| BrowserStack Accessibility | Cloud platform | ~30-40% of issues | Cross-browser testing at scale |
| Lighthouse CI | CLI | Subset of axe rules | Performance + a11y together |
| jest-axe | Jest matcher | ~30-40% of issues | Unit/integration tests |
| cypress-axe | Cypress plugin | ~30-40% of issues | E2E tests |
| playwright | Built-in | Basic checks | E2E with accessibility snapshots |
Example: jest-axe in Component Tests
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
describe('Button', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<Button onClick={() => {}}>Click me</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no violations when disabled', async () => {
const { container } = render(
<Button onClick={() => {}} disabled>
Disabled button
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Example: Playwright Accessibility Testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should have no automatically detectable violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('should have no violations on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});
Storybook Accessibility Addon
If you use Storybook for component development, the a11y addon runs axe on every story:
// .storybook/main.ts
export default {
addons: [
'@storybook/addon-a11y',
],
};
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
parameters: {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
],
},
},
},
};
export default meta;
export const Primary: StoryObj<typeof Button> = {
args: {
children: 'Primary Button',
variant: 'primary',
},
};
Mobile Accessibility: iOS and Android Patterns
Desktop accessibility gets most of the attention, but mobile users account for over 60% of web traffic. Mobile accessibility has unique challenges: touch targets, gesture alternatives, and platform-specific screen readers.
I recently tested a responsive web app that passed all desktop accessibility checks but was completely broken on mobile. The touch targets were 20px (below WCAG minimum), swipe-to-delete had no button alternative, and the team had never once tested with TalkBack. Don’t make the same mistake.
Touch Target Sizing
WCAG 2.2 introduced Success Criterion 2.5.8: Target Size (Minimum) requiring interactive elements to be at least 24×24 CSS pixels. But that’s the minimum. Apple’s Human Interface Guidelines recommend 44×44 points, and Google’s Material Design suggests 48×48dp.

Touch target size comparison: 24px meets WCAG minimum but is difficult to tap accurately. 44-48px provides comfortable interaction for users with varying motor abilities.
/* Minimum (WCAG 2.2 AA) */
.button-minimum {
min-width: 24px;
min-height: 24px;
}
/* Recommended (Apple HIG) */
.button-recommended {
min-width: 44px;
min-height: 44px;
}
/* Comfortable (Material Design) */
.button-comfortable {
min-width: 48px;
min-height: 48px;
/* Add padding for larger touch area without visual size increase */
padding: 12px;
}
/* Touch target expansion without visual change */
.icon-button {
position: relative;
width: 24px;
height: 24px;
}
.icon-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
}
TalkBack Testing (Android)
TalkBack is Android’s built-in screen reader. It works differently from VoiceOver, so test on both platforms.
| Action | Gesture |
|---|---|
| Enable TalkBack | Settings > Accessibility > TalkBack (or hold both volume keys) |
| Navigate next | Swipe right |
| Navigate previous | Swipe left |
| Activate element | Double tap |
| Scroll | Two-finger swipe |
| Open local context menu | Swipe up then right |
| Read from top | Swipe down then up |
| Stop speaking | Two-finger tap |
TalkBack-specific issues to test:
- Custom gestures must have tap alternatives
- Swipe-to-delete needs an accessible alternative (button or menu)
- Pull-to-refresh should be announced and have a button alternative
- Bottom sheets and drawers need proper focus management
iOS VoiceOver (Mobile)
Mobile VoiceOver differs from macOS VoiceOver. The gesture-based navigation requires specific testing.
| Action | Gesture |
|---|---|
| Enable VoiceOver | Settings > Accessibility > VoiceOver (or triple-click home/side button) |
| Navigate next | Swipe right |
| Navigate previous | Swipe left |
| Activate element | Double tap |
| Scroll | Three-finger swipe |
| Open rotor | Two-finger rotate |
| Read from top | Two-finger swipe up |
| Stop speaking | Two-finger tap |
iOS-specific patterns:
<!-- Grouping related elements for cleaner navigation -->
<div role="group" aria-label="Product: Running Shoes, $89.99">
<img src="shoes.jpg" alt="" /> <!-- Empty alt, info in group label -->
<span aria-hidden="true">Running Shoes</span>
<span aria-hidden="true">$89.99</span>
</div>
<!-- Trait hints for custom actions -->
<button
aria-label="Add to favorites"
aria-roledescription="toggle button"
>
<HeartIcon />
</button>
Gesture Alternatives
Any functionality triggered by gestures must have a single-pointer alternative (WCAG 2.5.1):
| Gesture | Required Alternative |
|---|---|
| Swipe to delete | Delete button or menu option |
| Pinch to zoom | Zoom buttons (+/-) |
| Pull to refresh | Refresh button |
| Long press | Context menu button |
| Multi-finger gestures | Single tap alternatives |
| Drag and drop | Move up/down buttons or select + move action |
// Bad: Swipe-only delete
function SwipeableItem({ onDelete }) {
return (
<SwipeableRow onSwipeLeft={onDelete}>
<ItemContent />
</SwipeableRow>
);
}
// Good: Swipe with button alternative
function AccessibleSwipeableItem({ onDelete }) {
return (
<SwipeableRow onSwipeLeft={onDelete}>
<ItemContent />
<button
aria-label="Delete item"
onClick={onDelete}
className="delete-button"
>
<TrashIcon aria-hidden="true" />
</button>
</SwipeableRow>
);
}
Enterprise Compliance: VPAT and ACR Documentation
If you’re selling to government agencies or large enterprises, you’ll need formal accessibility documentation.
This isn’t optional for procurement.
I learned this when a SaaS product I worked on lost a $2M higher education contract because we didn’t have a VPAT ready. The procurement team literally couldn’t move forward without it, regardless of how good our product was.
We scrambled to create one in two weeks, but by then they’d moved on to a competitor.
What is a VPAT?
A VPAT (Voluntary Product Accessibility Template) is a standardized document that explains how your product conforms to accessibility standards.
Despite “voluntary” in the name, it’s required for:
- US federal government procurement (Section 508)
- State and local government contracts
- Higher education institutions
- Many Fortune 500 companies
The current version is VPAT 2.5, which covers:
- WCAG 2.2 (Levels A, AA, AAA)
- Revised Section 508 standards
- EN 301 549 (European standard)
What is an ACR?
An ACR (Accessibility Conformance Report) is a completed VPAT.
The VPAT is the template; the ACR is your filled-out assessment.
When someone asks for your “VPAT,” they usually mean your ACR.
Conformance Levels in VPATs
| Level | Meaning | When to Use |
|---|---|---|
| Supports | Fully meets the criterion | Feature works accessibly |
| Partially Supports | Some functionality meets criterion | Document what works and what doesn’t |
| Does Not Support | Does not meet criterion | Be honest, include remediation timeline |
| Not Applicable | Criterion doesn’t apply | e.g., no audio content = audio criteria N/A |

VPAT conformance levels explained with concrete examples. Use “Supports” for full compliance, “Partially Supports” for minor issues, “Does Not Support” for failures, and “N/A” when the criterion doesn’t apply to your product.
Creating Your First ACR
-
Use the official template: Download from ITI VPAT Repository
-
Audit against WCAG 2.2 AA: Test every criterion, document findings
-
Be specific in remarks: Don’t just say “Supports.” Explain how.
<!-- Bad remark -->
1.1.1 Non-text Content: Supports
<!-- Good remark -->
1.1.1 Non-text Content: Supports
All informative images include descriptive alt text. Decorative images
use null alt attributes. Complex charts include extended descriptions
via aria-describedby linking to detailed text explanations. Icon buttons
use aria-label for accessible names.
-
Include testing methodology: List tools used (axe, NVDA, VoiceOver), browser/device combinations tested
-
Update regularly: ACRs should be updated with each major release
Legal Note: Your ACR is a legal document. Overstating conformance can expose your company to liability. If something “Partially Supports,” say so. Include your remediation roadmap for known issues. Honesty protects you better than optimism.
Where to Publish Your ACR
- Product documentation site (common location:
/accessibilityor/compliance) - Link from footer on marketing site
- Include in sales/procurement packages
- Register with VPAT Repository for discoverability
The 10 Most Common Accessibility Mistakes
After auditing dozens of codebases and conducting accessibility reviews for teams ranging from startups to Fortune 500 companies, these are the issues I see repeatedly. I’ve literally found every single one of these in production code this year. Bookmark this section.
1. Click Handlers on Non-Interactive Elements
This is the most common issue I find. I once audited a dashboard where 80% of the “buttons” were actually styled divs. The entire app was unusable with a keyboard.
// ❌ Wrong: div with click handler
<div onClick={handleClick} className="card">
Click me
</div>
// ✅ Correct: button element
<button onClick={handleClick} className="card">
Click me
</button>
// ✅ Also correct: if it navigates, use a link
<a href="/destination" className="card">
Click me
</a>
Why it matters: Divs aren’t focusable, don’t respond to Enter/Space, and aren’t announced as interactive.
2. Missing Form Labels
// ❌ Wrong: placeholder as label
<input type="email" placeholder="Email address" />
// ❌ Wrong: visual label without association
<span>Email address</span>
<input type="email" />
// ✅ Correct: explicit label
<label htmlFor="email">Email address</label>
<input type="email" id="email" />
// ✅ Also correct: aria-label for icon inputs
<input type="search" aria-label="Search products" />
Why it matters: Screen readers don’t read placeholders reliably, and unassociated labels aren’t announced.
3. Color as the Only Indicator
// ❌ Wrong: only color shows error state
<input
type="email"
className={hasError ? 'border-red' : 'border-gray'}
/>
// ✅ Correct: color + icon + text
<div>
<input
type="email"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
className={hasError ? 'border-red' : 'border-gray'}
/>
{hasError && (
<span id="email-error" className="error">
<ErrorIcon aria-hidden="true" />
Please enter a valid email address
</span>
)}
</div>
Why it matters: 8% of men have color vision deficiency. Color alone is invisible to them.
4. Missing Alt Text (or Bad Alt Text)
// ❌ Wrong: missing alt
<img src="product.jpg" />
// ❌ Wrong: useless alt
<img src="product.jpg" alt="image" />
<img src="product.jpg" alt="product.jpg" />
// ❌ Wrong: redundant alt
<img src="product.jpg" alt="Image of a product" />
// ✅ Correct: descriptive alt
<img src="product.jpg" alt="Blue Nike running shoes, side view" />
// ✅ Correct: decorative image
<img src="decorative-swirl.svg" alt="" />
Why it matters: Screen readers announce “image” for missing alt, or read the filename. Neither helps.
5. Removing Focus Indicators
/* ❌ Never do this */
*:focus {
outline: none;
}
/* ❌ Also bad: removing without replacement */
button:focus {
outline: none;
}
/* ✅ Correct: custom focus style */
button:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
Why it matters: Keyboard users can’t see where they are on the page without focus indicators.
6. Incorrect Heading Hierarchy
<!-- ❌ Wrong: skipping levels -->
<h1>Page Title</h1>
<h3>Section Title</h3> <!-- Skipped h2 -->
<h5>Subsection</h5> <!-- Skipped h4 -->
<!-- ❌ Wrong: multiple h1s -->
<h1>Site Name</h1>
<h1>Page Title</h1>
<!-- ✅ Correct: sequential hierarchy -->
<h1>Page Title</h1>
<h2>Section Title</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
Why it matters: Screen reader users navigate by headings. Skipped levels break their mental model.
7. Auto-Playing Media
// ❌ Wrong: auto-playing video
<video autoPlay src="promo.mp4" />
// ❌ Wrong: auto-playing with sound
<video autoPlay src="promo.mp4" />
// ✅ Correct: muted autoplay with controls
<video autoPlay muted controls src="promo.mp4">
<track kind="captions" src="promo.vtt" srclang="en" />
</video>
// ✅ Best: no autoplay, user-initiated
<video controls src="promo.mp4">
<track kind="captions" src="promo.vtt" srclang="en" />
</video>
Why it matters: Auto-playing audio interferes with screen readers. Auto-playing video can trigger vestibular issues.
8. Inaccessible Modals
// ❌ Wrong: no focus management
function BadModal({ isOpen, children }) {
if (!isOpen) return null;
return <div className="modal">{children}</div>;
}
// ✅ Correct: focus trap, escape to close, return focus
function GoodModal({ isOpen, onClose, children }) {
const modalRef = useFocusTrap(isOpen);
useEffect(() => {
const handleEscape = (e) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{children}
</div>
);
}
Why it matters: Without focus trapping, keyboard users get lost behind the modal overlay.
9. Links That Look Like Buttons (and Vice Versa)
// ❌ Wrong: link styled as button for non-navigation action
<a href="#" onClick={handleSubmit} className="button">
Submit Form
</a>
// ❌ Wrong: button for navigation
<button onClick={() => router.push('/about')} className="link">
About Us
</button>
// ✅ Correct: button for actions
<button onClick={handleSubmit} className="button">
Submit Form
</button>
// ✅ Correct: link for navigation
<a href="/about" className="link">
About Us
</a>
Why it matters: Screen readers announce “link” vs “button.” Users expect links to navigate and buttons to act.
10. Dynamic Content Without Announcements
This one bites teams building SPAs especially hard. If you’re using React state management to update UI without page reloads, you need live regions.
// ❌ Wrong: silent update
function BadNotification({ message }) {
return <div className="toast">{message}</div>;
}
// ✅ Correct: announced update
function GoodNotification({ message }) {
return (
<div role="status" aria-live="polite" className="toast">
{message}
</div>
);
}
// ✅ For urgent messages
function AlertNotification({ message }) {
return (
<div role="alert" aria-live="assertive" className="toast">
{message}
</div>
);
}
Why it matters: Screen readers don’t automatically announce DOM changes. Live regions fix this.
The Complete Accessibility Checklist
Use this for every component and page you ship:
Design Phase
- Color contrast verified in Figma (Stark/Able plugin)
- Touch targets are at least 24×24px
- Focus states designed for all interactive elements
- Error states don’t rely on color alone
- A11y annotations added for handoff
Development Phase
- Semantic HTML used (button, nav, main, article)
- Heading hierarchy is logical (h1 → h2 → h3)
- Images have appropriate alt text
- Form inputs have associated labels
- ARIA used only when necessary
- Focus order matches visual order
- Focus indicators are visible
- Skip link implemented
Testing Phase
- axe DevTools shows no violations
- Keyboard-only navigation works
- Screen reader announces content correctly
- Reduced motion preference respected
- 200% zoom doesn’t break layout
- Automated a11y tests pass in CI
What to Do Next
You don’t need to fix everything at once. Here’s a prioritized, time-boxed approach based on what will have the most impact:
Phase 1: This Week (2-3 hours)
Day 1: Install Testing Tools (30 minutes)
- Install axe DevTools browser extension (Chrome/Firefox/Edge)
- Install WAVE browser extension as a second opinion
- Optional: Install Accessibility Insights for guided assessments
Day 2: Run Your First Audit (1 hour)
- Open your production site’s homepage
- Run axe DevTools scan
- Document your baseline: How many Critical, Serious, Moderate, and Minor issues?
- Fix all “Critical” violations (typically 3-5 issues: missing alt text, form labels, color contrast)
Day 3: Test with Keyboard Only (1 hour)
- Unplug your mouse (seriously)
- Navigate your site using only Tab, Enter, Space, and Escape
- Can you reach every interactive element?
- Are focus indicators visible?
- Fix any keyboard traps or missing focus states
Phase 2: This Month (1 day)
Week 1: Add Automated Testing (3 hours)
# Install jest-axe
npm install --save-dev jest-axe @axe-core/react
# Or for Playwright
npm install --save-dev @axe-core/playwright
Write accessibility tests for your 5 most-used components (see examples in the article above). Add to CI pipeline to fail builds on Critical violations.
Week 2: Audit Design Tokens (2 hours)
- Use Stark plugin in Figma to check all color combinations
- Ensure text colors meet 4.5:1 contrast ratio
- Ensure UI components (buttons, inputs) meet 3:1 contrast ratio
- Document passing combinations in your design system
Week 3: Add prefers-reduced-motion (2 hours)
- Wrap all animations in
@media (prefers-reduced-motion: no-preference) - Test by enabling “Reduce motion” in your OS settings
- Ensure critical functionality still works without animation
Week 4: Screen Reader Testing (3 hours)
- Mac: Enable VoiceOver (Cmd+F5), navigate with Ctrl+Option+Arrow keys
- Windows: Install NVDA (free), navigate with arrow keys
- Test 10 key user flows: Can you complete them by listening only?
- Fix issues where content isn’t announced or navigation is confusing
Phase 3: This Quarter (1 week)
Sprint 1: Component Library Migration (2 days)
- Replace custom dropdowns, modals, and tabs with Radix Primitives or shadcn/ui
- Or use React Aria if you need internationalization
- Test each replacement with axe DevTools and screen reader
Sprint 2: Focus Management Audit (1 day)
- Check for Focus Not Obscured issues (WCAG 2.4.11) with sticky headers
- Add
scroll-padding-topandscroll-marginCSS properties - Ensure focus indicators meet 2.4.13 requirements (2px, 3:1 contrast)
Sprint 3: Drag-and-Drop Alternatives (1 day)
- Add keyboard alternatives to sortable lists (up/down buttons)
- Add click alternatives to drag-and-drop file uploads
- Test with keyboard only
Sprint 4: Documentation (1 day)
- Create accessibility checklist for your team (use the one in this article)
- Document accessibility patterns in Storybook or your component library
- Add A11y Annotation Kit to Figma components
Ongoing: Every Sprint (15 minutes per component)
Before merging any PR:
- Run axe DevTools on the changed pages
- Tab through the component with keyboard only
- Test with screen reader (VoiceOver or NVDA) for 5 minutes
- Check color contrast if colors changed
- Verify focus indicators are visible
Monthly:
- Run full accessibility audit with Pa11y or Lighthouse CI
- Review accessibility lawsuit trends (are you at risk?)
- Update team on new WCAG criteria or legal requirements
Resources to Bookmark
Testing Tools:
- axe DevTools - Browser extension
- WAVE - Visual feedback
- Accessibility Insights - Microsoft’s guided assessments
- NVDA - Free Windows screen reader
Component Libraries:
- Radix Primitives - Unstyled, accessible React components
- shadcn/ui - Copy-paste components built on Radix
- React Aria - Adobe’s accessible hooks
- Base UI - Next-gen Radix (2025)
Learning:
- WCAG 2.2 Quick Reference - Official W3C guide
- ARIA Authoring Practices Guide - Patterns and examples
- WebAIM Articles - Practical accessibility guides
The goal isn’t perfection. It’s continuous improvement.
Every accessibility fix you ship makes the web more usable for the 1.3 billion people with disabilities, and for everyone else dealing with situational impairments.
Related Resources:
- Building Design Systems from Scratch - Accessibility integration in design systems
- Framer Motion Complete Guide - Accessible animation patterns
- Building Interactive UI Components - Accessible React components with WCAG compliance
- What Is a Design Engineer? - Accessibility as a design engineering skill
- TypeScript for React Developers - Type-safe accessible component patterns