The problem
You need to ship a web app that meets WCAG 2.1 AA: the baseline most legal requirements and procurement processes reference: but the spec is dense and hard to map to actual component code. This checklist translates each criterion into patterns you can use during development, code review, and QA.
Principle 1: Perceivable
1.1 Text alternatives
1.1.1 Non-text content: Every non-text element needs a text alternative serving the same purpose.
// Good: Meaningful alt text <img src="/dashboard-chart.png" alt="Monthly revenue chart showing 23% growth from January to March 2026" /> // Good: Decorative image <img src="/decorative-wave.svg" alt="" /> // Good: Icon button with label <button aria-label="Close dialog"> <XIcon aria-hidden="true" /> </button> Key points:
- All
<img>elements have meaningfulaltdescribing purpose, not appearance - Decorative images use
alt=""so screen readers skip them - Icon buttons need
aria-labelor visually hidden text - SVG icons used as content need
role="img"andaria-label - Charts and data visualizations need text descriptions or accessible data tables
1.3 Adaptable
1.3.1 Info and relationships: Structure conveyed visually must be conveyed programmatically.
// Good: Proper table structure <table> <thead> <tr> <th scope="col">Name</th> <th scope="col">Role</th> <th scope="col">Status</th> </tr> </thead> <tbody> <tr> <td>Jane Smith</td> <td>Admin</td> <td>Active</td> </tr> </tbody> </table> Requirements:
- Headings use proper
<h1>through<h6>in logical order (no skipping levels) - Lists use
<ul>,<ol>, or<dl>: not styled<div>s - Data tables use
<table>,<thead>,<th>,<td>withscopeattributes - Form inputs associate with labels via
<label htmlFor="id">oraria-labelledby - Related fields grouped with
<fieldset>and<legend> - Landmarks:
<nav>,<main>,<aside>,<header>,<footer>
1.3.5 Identify input purpose: Form inputs for personal data use appropriate autocomplete attributes.
<input type="email" name="email" autoComplete="email" /> <input type="text" name="name" autoComplete="name" /> <input type="tel" name="phone" autoComplete="tel" /> 1.4 Distinguishable
1.4.3 Contrast (minimum): The criterion most products fail.
- Normal text (< 18pt / 24px): 4.5:1 contrast ratio
- Large text (18pt+ bold or 24px+ regular): 3:1
- Applies to both light and dark mode
- Placeholder text must meet contrast requirements
Tools: Chrome DevTools (Inspect > computed styles > contrast ratio), axe browser extension, Stark plugin for Figma.
1.4.4 Resize text: Text resizes to 200% without loss of content or functionality.
- Use relative units (
rem,em,%) instead ofpxfor font sizes - Layouts must not break at 200% browser zoom
1.4.10 Reflow: Content reflows at 320px viewport width (400% zoom on 1280px screen) without horizontal scrolling.
1.4.11 Non-text contrast: UI components (buttons, inputs, checkboxes, toggles) need 3:1 contrast against adjacent colors. Focus indicators, chart lines, and meaningful icons also need 3:1.
1.4.13 Content on hover or focus: Tooltips and popovers:
- Dismissible with Escape key without moving pointer
- Hoverable without disappearing
- Stay visible until dismissed, focus moves, or trigger unhovered
Principle 2: Operable
2.1 Keyboard accessible
2.1.1 Keyboard: Every interactive element reachable and operable via keyboard (Tab, Enter, Space, Arrow keys, Escape). Custom components (dropdowns, modals, accordions, tabs) need appropriate keyboard handlers.
2.1.2 No keyboard trap: Focus can always move away using standard navigation. Modals trap focus when open but release on close.
// Good: Modal with focus trap that releases on close <Dialog open={isOpen} onClose={() => setIsOpen(false)}> {/* Focus is trapped here while open */} <DialogPanel> <h2>Settings</h2> <button onClick={() => setIsOpen(false)}>Close</button> </DialogPanel> </Dialog> 2.2 Enough time
2.2.2 Pause, stop, hide: Moving, blinking, or auto-updating content can be paused, stopped, or hidden. Animations respect prefers-reduced-motion.
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } 2.4 Navigable
2.4.1 Bypass blocks: "Skip to main content" link as first focusable element. Landmark regions allow screen reader users to jump between sections.
// Skip link as first element in layout <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded"> Skip to main content </a> 2.4.3 Focus order: Tab order follows logical sequence matching visual layout. Dynamically added content (modals, toasts, expanded sections) receives focus appropriately. No tabindex values greater than 0.
2.4.7 Focus visible: All interactive elements have visible focus indicator when keyboard-navigated.
/* Good: Clear, visible focus indicator */ :focus-visible { outline: 2px solid #685FD4; outline-offset: 2px; } 2.5 Input modalities
2.5.3 Label in name: Visible text label of a component is included in its accessible name. A button saying "Submit" has an accessible name containing "Submit" (not "btn-action-01").
Principle 3: Understandable
3.1 Readable
3.1.1 Language of page: <html> element has correct lang attribute.
<html lang="en"> 3.1.2 Language of parts: Content in different language wrapped with appropriate lang attribute.
3.2 Predictable
3.2.3 Consistent navigation: Navigation menus appear in same location and order across pages.
3.2.4 Consistent identification: Components with same function have same label across the app. "Save" button is always "Save," not sometimes "Submit" or "Confirm."
3.3 Input assistance
3.3.1 Error identification: Form errors identified in text, not just color. Messages specify which field and what's wrong.
3.3.3 Error suggestion: When error detected and suggestions available, provide them. "Invalid email" suggests "Enter an email address like name@example.com."
3.3.4 Error prevention (legal, financial, data): Submissions involving legal, financial, or data changes are reversible, verified, or confirmed.
// Good: Confirmation before destructive action <Dialog> <DialogTitle>Delete account?</DialogTitle> <DialogDescription> This will permanently delete your account and all associated data. This action cannot be undone. </DialogDescription> <button onClick={onCancel}>Cancel</button> <button onClick={onConfirm}>Delete account</button> </Dialog> Principle 4: Robust
4.1 Compatible
4.1.2 Name, role, value: All interactive components have accessible name, role, and state. Custom components use appropriate ARIA roles. State changes communicated via aria-expanded, aria-selected, aria-checked, aria-disabled.
// Good: Custom accordion with proper ARIA <div role="region" aria-labelledby="section-heading"> <button aria-expanded={isOpen} aria-controls="section-content" id="section-heading" > Account Settings </button> <div id="section-content" role="region" hidden={!isOpen}> {/* Settings content */} </div> </div> 4.1.3 Status messages: Status messages announced to screen readers without receiving focus. Use role="status", role="alert", or aria-live="polite".
// Good: Toast notification announced to screen readers <div role="status" aria-live="polite"> {savedSuccessfully && "Settings saved successfully."} </div> // Good: Alert for errors <div role="alert"> {error && `Error: ${error.message}`} </div> Testing checklist
Automated testing catches only ~30% of issues. Run this minimum routine:
Automated
- Run axe DevTools or Lighthouse accessibility audit on every page
- Add
eslint-plugin-jsx-a11yto ESLint config for build-time checks - Include accessibility checks in CI pipeline
Manual keyboard
- Navigate entire app using only Tab, Shift+Tab, Enter, Space, Arrow keys, Escape
- Complete every user flow without a mouse
- Verify focus indicators always visible during keyboard navigation
Screen reader
- Test with VoiceOver (macOS) or NVDA (Windows) on main user flows
- Verify headings, landmarks, and form labels announced correctly
- Confirm dynamic content changes (loading states, errors, new items) announced
Visual
- Test at 200% browser zoom
- Test at 320px viewport width
- Test with
prefers-reduced-motion: reduceenabled - Test in both light and dark mode
- Use contrast checker on all text and interactive elements
Component libraries that handle accessibility
Building every pattern from scratch is error-prone. These libraries provide accessible primitives:
- shadcn/ui + Radix UI: Unstyled, accessible primitives styled with Tailwind CSS
- Headless UI: Accessible components from the Tailwind CSS team
- React Aria (Adobe): Low-level accessibility hooks for custom components
Start with accessible primitives, then maintain compliance as you customize.
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.
