Accessibility for Design Engineers: WCAG 2.2 Guide

Master accessible UI design with semantic HTML, ARIA, keyboard navigation, and WCAG 2.2 compliance. Practical guide for design engineers with examples.

Inzimam Ul Haq
Inzimam Ul Haq
· 20 min read · Updated
Person using assistive technology on a laptop, representing inclusive design
Photo by Sigmund on Unsplash

94.8% of the top million websites fail basic accessibility tests (WebAIM Million Report). With EAA penalties up to EUR80,000 (enforced June 2025) and WCAG 2.2 now ISO/IEC 40500:2025, accessibility must be a core design constraint.

TL;DR

  • Use semantic HTML (button, nav, main) over generic divs
  • Color contrast: 4.5:1 for text, 3:1 for UI components
  • Keyboard accessible: Tab, Enter, Space, Escape
  • Test with axe DevTools + VoiceOver/NVDA
  • Respect prefers-reduced-motion
  • Use Radix UI or React Aria
  • Touch targets: 44x44 CSS pixels minimum

What is WCAG 2.2?

WCAG (Web Content Accessibility Guidelines) is the W3C standard. Target Level AA for most projects.

LevelTarget
AMinimum - never target alone
AADefault for all projects
AAASpecialized apps, government

POUR Principles

PrincipleActions
PerceivableAlt text, 4.5:1 contrast, never rely solely on color
OperableKeyboard access, skip links, touch targets 44px+
UnderstandableClear labels, predictable navigation, error identification
RobustValid HTML, proper ARIA, screen reader testing

WCAG 2.2 New Criteria

Level AA:

  • 2.5.8 Target Size: Interactive targets at least 24x24px (44x44px recommended)

Level AAA:

  • 3.3.9 Accessible Authentication: No object recognition required

Sprint Checklist:

  • Audit sticky headers for focus obscuring
  • Touch targets 24px minimum (44px recommended)
  • Remove CAPTCHA-style authentication

Design System Accessibility: Tokens and Figma

Accessible Color Tokens

// 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)
  },

  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;

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

components. Use contrast checkers to verify your color combinations meet accessibility standards.

Figma Accessibility Plugins

PluginPurposeCost
StarkContrast checker, vision simulatorFree tier
A11y Annotation KitDocument a11y specs for handoffFree
AbleContrast checkerFree

Reduced Motion Tokens

// 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);
}

@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

Decorative Icons (Icon + Text)

Hide decorative icons from screen readers:

// ✅ Correct: Icon is decorative, text provides the label
<button>
    <path d="M5 13l4-4-4-4" />
  </svg>
  Save Changes
</button>

<button>
  <svg role="img" aria-label="Save icon">
    <path d="..." />
  </svg>
  Save Changes
</button>

Informative Icons (Icon-Only Buttons)

<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>

<button>
  <svg aria-label="Save changes">
    <path d="..." />
  </svg>
</button>

Standalone SVGs (Charts, Diagrams)

For informational SVGs, use <title> and <desc>:

// ✅ Correct: SVG as content with title and description
  <desc id="chart-desc">
    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

LibraryAccessibilityNotes
LucideExcellentReact components with proper ARIA
Phosphor IconsGoodAccessible SVG sprites
Material IconsMixedFont-based icons need extra care

Common SVG Mistakes

MistakeFix
aria-label on SVG instead of buttonPut aria-label on the button
Missing focusable="false"Always add focusable="false"
Complex SVG without text alternativeProvide data table or description

Tooltip Accessibility

Requirements:

  1. Trigger must be focusable
  2. Appears on hover AND focus
  3. Closes with Escape key
  4. Use role="tooltip" and aria-describedby

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 && (
          {content}
        </div>
      )}
    </>
  );
}

// Usage
<AccessibleTooltip
  trigger={<InfoIcon />}
/>;

Tooltip vs Popover

FeatureTooltipPopover
ContentText onlyLinks, buttons, forms
TriggerHover/focusClick/tap
DismissalAutomaticManual
ARIArole="tooltip"role="dialog"

Rule: If it contains interactive elements, use a popover.

Tooltip Libraries

Use Radix UI Tooltip instead of building from scratch.

Semantic HTML vs ARIA

The Accessibility Tree

The accessibility tree exposes:

  • Role: What the element is (button, link, heading)
  • Name: The accessible label
  • Value: Current value for inputs
<!-- DOM -->
<div class="card">
  <div class="card-image">
  </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.

Viewing the Accessibility Tree

Chrome/Edge: DevTools > Elements > Select element > Accessibility tab

Firefox: DevTools > Accessibility tab

What to Look For:

  • Images have alt text or are marked decorative
  • Headings are in logical order (h1, h2, h3)
  • No generic roles (div, span) in interactive areas
  • No missing accessible names on focusable elements

Accessible Name Priority

  1. aria-labelledby - Reference existing text
  2. aria-label - Provides a string label
  3. Element content - Button text, link text
  4. title attribute - Last resort

Common ARIA Mistakes

MistakeFix
<div role="button">Use <button>
role="link" without hrefUse <a href="...">
aria-hidden="true" on focusableRemove from tab order first
Redundant role="button" on <button>Remove the role

When ARIA is 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>
    Reviews
  </button>
</div>
<div role="tabpanel" id="panel-1">...</div>
<div role="tabpanel" id="panel-2" hidden>...</div>

Keyboard Navigation Patterns

Keyboard Contract

KeyBehavior
TabMove to next focusable element
Shift+TabMove to previous
EnterActivate buttons, submit forms, follow links

Roving Tabindex

  const [focusedIndex, setFocusedIndex] = useState(0);

  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}
          role="tab"
          aria-selected={activeTab === tab.id}
          aria-controls={`panel-${tab.id}`}
          onClick={() => onTabChange(tab.id)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

Focus Trapping in Modals

Focus trapping prevents keyboard users from tabbing outside an open modal.

Tab from the last element returns to the first. Escape closes the modal.

  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {

      // Focus first focusable element or the modal itself
      const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
      );

      if (focusable?.length) {
        focusable[0].focus();
      } else {
        modalRef.current?.focus();
      }
    } else if (previousFocus.current) {
      previousFocus.current.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;

      if (event.key === "Escape") {
        onClose();
        return;
      }

      if (event.key !== "Tab") return;

      const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
      );

      if (!focusable?.length) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
        event.preventDefault();
        first.focus();
      }
    };

    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, onClose]);

  return modalRef;
}

// Usage
function Modal({ isOpen, onClose, title, children }) {

  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>
  );
}

The Native <dialog> Element


  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
    } else {
      dialog.close();
    }
  }, [isOpen]);

  return (
    <dialog ref={dialogRef} onClose={onClose}>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

What <dialog> gives you for free:

  • Focus trapping (no custom implementation needed)
  • Escape key closes the dialog
  • Background content becomes inert automatically
  • ::backdrop pseudo-element for overlay styling
  • Proper role="dialog" semantics

When you still need a custom modal: When you need animations (dialog doesn’t animate natively), nested modals, or non-modal drawer patterns.

Focus Not Obscured: WCAG 2.2’s Trickiest New Criterion

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 (
    <>
        <nav>...</nav>
      </header>
      <main className="pt-16">
        {children} {/* First focusable element hidden under header */}
      </main>
    </>
  );
}

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:

  1. Tab through your entire page with a sticky header visible

Drag-and-Drop Accessibility: WCAG 2.5.7

// ❌ Problem: Drag-only reordering
function SortableList({ items, onReorder }) {
  return (
    <DragDropContext onDragEnd={onReorder}>
      <Droppable droppableId="list">
        {(provided) => (
            {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
    const newIndex = direction === "up" ? index - 1 : index + 1;

    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>
  );
}

Note: react-beautiful-dnd was deprecated by Atlassian in 2024.

Accessible File Upload (Drag + Click Alternative):

function AccessibleFileUpload({ onUpload }) {
  const [isDragging, setIsDragging] = useState(false);

  const handleDrop = (e: DragEvent) => {
    e.preventDefault();
    setIsDragging(false);
    onUpload(files);
  };

    onUpload(files);
  };

  return (
    <div
      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"
      />
        <UploadIcon aria-hidden="true" />
        <button
          type="button"
          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}: {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.

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

When to Use inert

function Modal({ isOpen, onClose, children }) {
  useEffect(() => {
    if (isOpen) {
      appRoot?.setAttribute("inert", "");
    } else {
      appRoot?.removeAttribute("inert");
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
      <h2 id="modal-title">Settings</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}
function MultiStepForm({ currentStep }) {
  return (
    <div>
      <div inert={currentStep !== 1 ? "" : undefined}>
        <Step1 />
      </div>
        <Step2 />
      </div>
        <Step3 />
      </div>
    </div>
  );
}
function Accordion({ isOpen, children }) {
  return (
    <div>
      <button onClick={toggle} aria-expanded={isOpen}>
        Toggle
      </button>
        {children}
      </div>
    </div>
  );
}

inert vs Manual Focus Trapping

ApproachProsCons
inert attributeSimple, browser-native, prevents mouse clicks, no JS neededRequires polyfill for older browsers
Manual focus trapWorks in all browsers, fine-grained controlComplex code, easy to get wrong, doesn’t prevent mouse clicks

Polyfill for Older Browsers

npm install wicg-inert
import "wicg-inert";

// Now inert works in all browsers

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)
  • Disabled form sections (mark section as inert)
  • Off-canvas navigation (mark main content as inert when menu is open)
<!-- First element in <body> -->

<!-- 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 ReaderPlatformCostMarket ShareBest For
NVDAWindowsFree~40%Primary Windows testing
JAWSWindows$1000+/year~30%Enterprise compliance

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 | | Open Rotor | Ctrl + Option + U |

NVDA Quick Reference (Windows)

ActionShortcut
Start NVDADesktop shortcut or Ctrl + Alt + N
Navigate (browse mode)Arrow keys
Activate elementEnter
Tab through formsTab
Elements listNVDA + F7
Next headingH
Next landmarkD

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)
  • 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

Dynamic Content

  • Loading states are announced
  • Toast notifications are announced
  • Form submission results are announced
  • Content changes in SPAs are announced

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?

LibraryFrameworkStylingComponentsBest For
Radix UI PrimitivesReactUnstyled30+Design systems, full control
Base UIReactUnstyled20+From MUI team, unstyled headless primitives with fresh API design
Headless UIReact, VueUnstyled15+Tailwind projects

Design Engineer Tip: Use Radix Primitives for unstyled, fully accessible, and composable components.

Example: Accessible Dropdown with Radix


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}>
            Profile
          </DropdownMenu.Item>
          <DropdownMenu.Item className="dropdown-item">
            Settings
          </DropdownMenu.Item>
          <DropdownMenu.Separator className="dropdown-separator" />
            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

shadcn/ui: Copy-Paste Accessible Components

It’s not a traditional component library - it’s a collection of copy-paste components built on Radix Primitives and styled with Tailwind CSS.

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

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 | | label-has-associated-control | Labels without inputs | <label>Email</label> ❌ | | no-autofocus | Autofocus on page load | <input autoFocus /> ⚠️ |

Real-World Example:

<div onClick={handleClick}>
  Click me
</div>

// ✅ Fixed: Use a button
<button onClick={handleClick}>
  Click me
</button>

<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:

  • 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/no-static-element-interactions": "error"
  }
}

Integration with CI/CD:

Modern AI-augmented development workflows can automate accessibility checks alongside other quality gates:

# .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

| -------------------------- | ------------------------------------------------- | ---------------------- | ---- | | eslint-plugin-jsx-a11y | React-specific issues (missing alt, click on div) | While typing | 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.

Animation Accessibility: Respecting Motion Preferences

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 | | Flashing (>3 times/second) | Transform: translate (small) |

The prefers-reduced-motion Pattern

/* 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 (
      {children}
    </motion.div>
  );
}

The prefers-contrast Media Query

@media (prefers-contrast: more) {
  :root {
    --border-color: #000;
    --text-secondary: #1a1a1a;
    --bg-subtle: #f0f0f0;
  }

  button,
  input,
  select,
  textarea {
    border: 2px solid #000;
  }
}

@media (prefers-contrast: less) {
  :root {
    --text-primary: #444;
    --bg-primary: #fafafa;
  }
}

Accessible Loading States

function LoadingIndicator() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <div role="status" aria-label="Loading">
      {shouldReduceMotion ? (
      ) : (
        <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

Automated Testing Tools

| ------------------------------ | ----------------- | ------------------- | ---------------------------------- | | 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 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

// .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

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.

Touch target size comparison showing 24px WCAG minimum, 44px Apple recommendation, and 48px Google Material Design optimal size, compared to average adult fingertip size of 45-57px

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;
  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)

| Action | Gesture | | Navigate next | Swipe right | | Navigate previous | Swipe left | | Activate element | Double tap | | Scroll | Two-finger swipe | | 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)

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 | | Read from top | Two-finger swipe up | | Stop speaking | Two-finger tap |

iOS-specific patterns:

<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 -->
  <HeartIcon />
</button>

Gesture Alternatives

| 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

This isn’t optional for procurement.

What is a VPAT?

A VPAT (Voluntary Product Accessibility Template) is a standardized document that explains how your product conforms to accessibility standards.

  • 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

LevelMeaningWhen to Use
SupportsFully meets the criterionFeature works accessibly
Partially SupportsSome functionality meets criterionDocument what works and what doesn’t
Does Not SupportDoes not meet criterionBe honest, include remediation timeline
VPAT conformance levels table showing Supports for full compliance, Partially Supports for minor issues, Does Not Support for failures, and N/A when criterion doesn't apply, with keyboard navigation examples for each level

VPAT conformance levels explained with concrete examples. Use “Supports” for for failures, and “N/A” when the criterion doesn’t apply to your product.

Creating Your First ACR

  1. Audit against WCAG 2.2 AA: Test every criterion, document findings
<!-- Bad remark -->

1.1.1 Non-text Content: Supports

<!-- Good remark -->

1.1.1 Non-text Content: Supports
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.
  1. Include testing methodology: List tools used (axe, NVDA, VoiceOver), browser/device combinations tested

  2. Update regularly: ACRs should be updated with each major release

Legal Note: Your ACR is a legal document.

Where to Publish Your ACR

  • Link from footer on marketing site
  • Include in sales/procurement packages
  • Register with VPAT Repository for discoverability

The 10 Most Common Accessibility Mistakes

Here are the most common mistakes:

1. Click Handlers on Non-Interactive Elements

// ❌ 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" />

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}
  />
  {hasError && (
    <span id="email-error" className="error">
      <ErrorIcon aria-hidden="true" />
      Please enter a valid email address
    </span>
  )}
</div>

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">
</video>

// ✅ Best: no autoplay, user-initiated
<video controls src="promo.mp4">
</video>

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();
  }, [onClose]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      {children}
    </div>
  );
}
<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

// ❌ 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

  • 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

  • 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)

  1. Install WAVE browser extension as a second opinion
  2. Optional: Install Accessibility Insights for guided assessments

Day 2: Run Your First Audit (1 hour)

  1. Open your production site’s homepage
  2. Run axe DevTools scan
  3. Fix all “Critical” violations (typically 3-5 issues: missing alt text, form labels, color contrast)

Day 3: Test with Keyboard Only (1 hour)

  1. Unplug your mouse (seriously)
  2. Navigate your site using only Tab, Enter, Space, and Escape
  3. Can you reach every interactive element?
  4. Are focus indicators visible?
  5. 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)

  1. Use Stark plugin in Figma to check all color combinations
  2. Ensure text colors meet 4.5:1 contrast ratio
  3. Ensure UI components (buttons, inputs) meet 3:1 contrast ratio
  4. Document passing combinations in your design system

Week 3: Add prefers-reduced-motion (2 hours)

  1. Wrap all animations in @media (prefers-reduced-motion: no-preference)
  2. Test by enabling “Reduce motion” in your OS settings
  3. Ensure critical functionality still works without animation

Week 4: Screen Reader Testing (3 hours)

  1. Mac: Enable VoiceOver (Cmd+F5), navigate with Ctrl+Option+Arrow keys
  2. Windows: Install NVDA (free), navigate with arrow keys
  3. Test 10 key user flows: Can you complete them by listening only?
  4. 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-top and scroll-margin CSS 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:

Component Libraries:

Learning:

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:

Frequently Asked Questions

What is WCAG and which level should I target?
WCAG (Web Content Accessibility Guidelines) is the W3C standard for web accessibility with three levels: A (minimum), AA (standard), and AAA (enhanced). Target WCAG 2.2 Level AA for most projects, as this satisfies legal requirements including the European Accessibility Act and ADA compliance.
What is the minimum color contrast ratio for WCAG compliance?
WCAG 2.2 AA requires a minimum contrast ratio of 4.5:1 for normal text (under 18px or 14px bold) and 3:1 for large text (18px+ or 14px+ bold) and UI components like buttons and form inputs.
When should I use ARIA instead of semantic HTML?
Use semantic HTML whenever a native element exists (button, nav, main). Use ARIA only for custom widgets without HTML equivalents (tabs, comboboxes, tree views), to provide accessible names for icon buttons, to distinguish multiple landmarks, and to announce dynamic content changes with aria-live regions.
How do I test accessibility with a screen reader?
On macOS, enable VoiceOver with Cmd+F5 and navigate with Ctrl+Option+Arrow keys. On Windows, download NVDA (free) and navigate with arrow keys in browse mode. Test that headings are announced in order, form labels are read, and dynamic updates are announced.
What is prefers-reduced-motion and why does it matter?
prefers-reduced-motion is a CSS media query that detects when users have enabled reduced motion in their OS settings. About 35% of adults over 40 experience vestibular dysfunction, and motion can cause dizziness or nausea. Always wrap animations in @media (prefers-reduced-motion: no-preference) to respect this setting.
What accessibility tools should design engineers use?
Use Stark or Able in Figma for design-phase contrast checking. Use axe DevTools browser extension for automated testing (catches 30-40% of issues). Use eslint-plugin-jsx-a11y for React linting. Manually test with VoiceOver (Mac) or NVDA (Windows) for the remaining 60-70% of issues.
What are accessible component libraries for React?
Radix UI, React Aria (Adobe), and Headless UI (Tailwind) provide unstyled, fully accessible primitives. These handle keyboard navigation, focus management, and ARIA attributes so you can focus on styling. Avoid building custom dropdowns, modals, or tabs from scratch.
What is the accessibility tree?
The accessibility tree is a simplified version of the DOM that browsers expose to assistive technologies. It contains only semantically relevant nodes with their computed accessible names, roles, and states. Screen readers navigate this tree, not the visual DOM, which is why semantic HTML matters.
What is a VPAT and when do I need one?
A VPAT (Voluntary Product Accessibility Template) is a standardized document explaining how your product conforms to accessibility standards. You need a completed VPAT (called an ACR) for US federal procurement, state/local government contracts, higher education sales, and many enterprise deals. Use VPAT 2.5 which covers WCAG 2.2, Section 508, and EN 301 549.
What is the minimum touch target size for mobile accessibility?
WCAG 2.2 AA requires interactive touch targets to be at least 24×24 CSS pixels. However, Apple recommends 44×44 points and Google Material Design recommends 48×48dp for comfortable touch interaction. Use CSS padding or pseudo-elements to expand touch targets without changing visual size.

Sources & References