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 meaningful alt describing purpose, not appearance
  • Decorative images use alt="" so screen readers skip them
  • Icon buttons need aria-label or visually hidden text
  • SVG icons used as content need role="img" and aria-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> with scope attributes
  • Form inputs associate with labels via <label htmlFor="id"> or aria-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 of px for 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-a11y to 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: reduce enabled
  • 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.