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 Design Engineer
· 38 min
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.

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

  1. How to audit design tokens for WCAG contrast compliance
  2. Semantic HTML vs ARIA: a decision framework
  3. Keyboard patterns for custom components (roving tabindex, focus trapping)
  4. Screen reader testing workflow for macOS and Windows
  5. 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:

Metric20242025Trend
Pages with WCAG failures95.9%94.8%Slight improvement
Average errors per page5751-10.3%
Error rate per element4.3%4.1%1 barrier every 24 elements
Low contrast text81%78%Still dominant
Missing alt text54%52%Barely moving
Empty links45%44%Stagnant

The industry is getting marginally better, but 94.8% failure rate is still embarrassing. More importantly, it’s increasingly expensive.

RegulationDeadlineScopeStandardPenalties
European Accessibility Act (EAA)June 28, 2025 (Active)EU digital products/services, 10+ employees, €2M+ revenueEN 301 549 (WCAG 2.1 AA)Up to €80,000 (€50,000 for SMEs)
ADA Title IIApril 2026US state/local government sitesWCAG 2.1 AALawsuits, injunctions
Section 508 RefreshActiveUS federal agenciesWCAG 2.0 AAContract loss
AODAActiveOntario businessesWCAG 2.0 AAUp 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:

LevelWhat It MeansWhen to Target
AMinimum accessibility, removes absolute barriersNever target A alone
AAStandard compliance, covers most user needsDefault target for all projects
AAAEnhanced accessibility, maximum inclusionSpecialized 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:

PrincipleCore QuestionDesign Engineer Actions
PerceivableCan users perceive the content?Alt text, captions, 4.5:1 contrast, don’t rely on color alone
OperableCan users operate the interface?Keyboard access, no seizure triggers, skip links, touch targets ≥24px
UnderstandableCan users understand the content?Clear labels, predictable navigation, error identification
RobustDoes 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 table showing 4.5:1 ratio for normal text AA, 7:1 for AAA, 3:1 for large text and UI components, with hex color examples and common mistakes like #999999 on white failing at 2.8:1

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

PluginPurposeWhen to UseCost
StarkContrast checker, vision simulator, WCAG complianceEvery color decisionFree tier available
A11y Annotation KitDocument a11y specs for handoffBefore dev handoffFree
AbleContrast checker (free alternative)Budget-conscious teamsFree
Color BlindSimulate color blindness typesValidating color-only indicatorsFree
WAVEAccessibility evaluationFinal design reviewFree

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

LibraryAccessibilityNotes
Heroicons✅ ExcellentIncludes aria-hidden="true" by default
Lucide✅ ExcellentReact components with proper ARIA
Phosphor Icons✅ GoodAccessible SVG sprites
Font Awesome⚠️ MixedRequires manual aria-hidden on decorative icons
Material Icons⚠️ MixedFont-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

MistakeProblemFix
No aria-hidden on decorative iconsScreen reader announces “image” or SVG contentAdd aria-hidden="true"
aria-label on SVG instead of buttonInconsistent screen reader supportPut aria-label on the button
Missing focusable="false"SVG becomes focusable in IEAlways add focusable="false"
Using <title> for decorative iconsScreen reader announces titleUse aria-hidden="true" instead
Complex SVG without text alternativeChart data inaccessibleProvide 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:

  1. Trigger must be focusable (button, link, or tabindex="0")
  2. Tooltip appears on both hover AND focus
  3. Tooltip closes with Escape key
  4. Tooltip closes when focus moves away
  5. Use role="tooltip" on the tooltip container
  6. Connect with aria-describedby on 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

FeatureTooltipPopover
ContentText onlyCan include links, buttons, forms
TriggerHover or focusClick or tap
DismissalAutomatic (on blur/mouseout)Manual (close button or click outside)
KeyboardEscape to closeEscape to close, Tab to navigate inside
ARIA Rolerole="tooltip"role="dialog" or no role
Use CaseSupplemental 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

MistakeProblemFix
Hover-only tooltipKeyboard users can’t accessAdd onFocus and onBlur handlers
Links inside tooltipTooltip closes before link is clickableUse a popover instead
Using aria-label instead of aria-describedbyReplaces button’s accessible nameUse aria-describedby for supplemental info
No Escape key handlerKeyboard users can’t dismissAdd onKeyDown handler
Tooltip on non-focusable elementKeyboard users never see itAdd tabindex="0" or use a button

Tooltip Libraries with Good Accessibility

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 for choosing between HTML and ARIA - shows decision paths from needing an interactive element to using native HTML, ARIA widget roles, or semantic HTML with labels

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"
-->
Diagram showing DOM structure translating to accessibility tree - the browser filters generic divs and keeps only semantic elements like img, heading, text, and button

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:

  1. Open DevTools (F12)
  2. Go to Elements tab
  3. Select an element in the DOM
  4. Click Accessibility tab in the right panel
  5. 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:

  1. Open DevTools (F12)
  2. Go to Accessibility tab (top toolbar)
  3. Enable “Check for issues”
  4. Expand the document node to see the full tree
  5. 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):

  1. aria-labelledby - References another element’s text
  2. aria-label - Provides a string label
  3. Native label association - <label for="..."> for form inputs
  4. Element content - Button text, link text, heading text
  5. title attribute - Last resort, inconsistent support
  6. placeholder - 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:

MistakeProblemFix
Using placeholder as labelDisappears when typing, not announced reliablyUse <label> element
Using title for critical infoInconsistent screen reader supportUse aria-label or visible text
Redundant labels<button aria-label="Close">Close</button>Remove aria-label, use button text
Empty accessible nameIcon-only button with no labelAdd aria-label or visually hidden text
aria-label on non-interactive elementIgnored by screen readersUse semantic HTML or add role

How to Check: Open Chrome DevTools > Elements > Select element > Accessibility tab > “Computed Properties” > “Name”

Common ARIA Mistakes

MistakeProblemFix
<div role="button">No keyboard support, no form submissionUse <button>
aria-label on <div>Divs aren’t interactive, label is ignoredAdd a role or use semantic element
role="link" without hrefNot keyboard focusableUse <a href="...">
aria-hidden="true" on focusable elementCreates focus trapRemove from tab order first with tabindex="-1"
Redundant role="button" on <button>Unnecessary, clutters codeRemove the role
aria-label vs aria-labelledby confusionWrong attribute for the use caseSee quick reference below

ARIA Quick Reference: aria-label vs aria-labelledby vs aria-describedby

AttributeWhen to UseExample
aria-labelProvide a label when no visible text exists<button aria-label="Close dialog"><X /></button>
aria-labelledbyReference an existing element’s text as the label<dialog aria-labelledby="dialog-title"><h2 id="dialog-title">Settings</h2></dialog>
aria-describedbyAdd 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.

ARIA labeling decision matrix showing when to use aria-label for icon buttons, aria-labelledby to reference existing text, and aria-describedby for supplemental information, with code examples and common mistakes

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

KeyExpected Behavior
TabMove to next focusable element
Shift+TabMove to previous focusable element
EnterActivate buttons, submit forms, follow links
SpaceActivate buttons, toggle checkboxes
Arrow keysNavigate within composite widgets
EscapeClose modals, cancel operations
Home/EndJump 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:

  1. On open: Store previous focus, move focus into modal
  2. While open: Cycle Tab between first and last focusable elements
  3. On close: Return focus to the trigger element
Focus trap logic flow diagram showing the circular tab navigation pattern - modal opens, stores previous focus, moves to first element, cycles between first and last elements with Tab and Shift+Tab, then restores focus on close

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.

Side-by-side comparison showing focus obscured failure where a focused button is hidden under a sticky header versus the pass scenario where scroll-padding keeps the focused button visible

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:

  1. Tab through your entire page with a sticky header visible
  2. Check that every focused element is at least partially visible
  3. Test with cookie banners, chat widgets, and floating CTAs active
  4. 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

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

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 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 ReaderPlatformCostMarket ShareBest For
NVDAWindowsFree~40%Primary Windows testing
JAWSWindows$1000+/year~30%Enterprise compliance
VoiceOvermacOS/iOSBuilt-in~15%Mac developers
TalkBackAndroidBuilt-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)

ActionShortcut
Toggle VoiceOverCmd + F5
Navigate nextCtrl + Option + Right Arrow
Navigate previousCtrl + Option + Left Arrow
Activate elementCtrl + Option + Space
Read allCtrl + Option + A
Open RotorCtrl + Option + U
Stop speakingCtrl

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
Stop speakingCtrl
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)
  • 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?

LibraryFrameworkStylingComponentsBest For
Radix UI PrimitivesReactUnstyled30+Design systems, full control
Base UIReactUnstyled20+Next-gen Radix (2025), fresh codebase
React AriaReactUnstyled40+Complex widgets, i18n (30+ languages)
Headless UIReact, VueUnstyled15+Tailwind projects
Ark UIReact, Vue, SolidUnstyled35+Multi-framework teams
shadcn/uiReactCopy/paste50+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:

RuleWhat It PreventsExample
alt-textImages without alt text<img src="..." />
click-events-have-key-eventsClick handlers on divs<div onClick={...} />
no-noninteractive-element-interactionsClick handlers on <p>, <div><p onClick={...} />
label-has-associated-controlLabels without inputs<label>Email</label>
aria-roleInvalid ARIA roles<div role="invalid" />
aria-propsInvalid ARIA attributes<div aria-fake="true" />
no-autofocusAutofocus 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

ToolWhat It CatchesWhen It RunsCost
eslint-plugin-jsx-a11yReact-specific issues (missing alt, click on div)While typingFree
axe DevTools30-40% of WCAG issuesManual browser testingFree
Pa11y CIAutomated WCAG checksCI/CD pipelineFree
LighthouseCore Web Vitals + accessibility scoreCI/CD or manualFree
VoiceOver/NVDAReal screen reader experienceManual testingFree

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 scrollingOpacity fades
Large-scale zoomingColor transitions
Spinning/rotating elementsSubtle hover states
Auto-playing carouselsUser-triggered animations
Background videoBorder/shadow changes
Infinite animationsSingle-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

ToolTypeCatchesBest For
axe DevToolsBrowser extension~30-40% of issuesManual testing, instant feedback
axe-coreLibrary~30-40% of issuesIntegration into any test runner
WAVEBrowser extension~30-40% of issuesVisual feedback, free
Accessibility InsightsBrowser extension~30-40% of issuesMicrosoft tool, guided assessments
Pa11yCLI~30-40% of issuesCI/CD pipelines
BrowserStack AccessibilityCloud platform~30-40% of issuesCross-browser testing at scale
Lighthouse CICLISubset of axe rulesPerformance + a11y together
jest-axeJest matcher~30-40% of issuesUnit/integration tests
cypress-axeCypress plugin~30-40% of issuesE2E tests
playwrightBuilt-inBasic checksE2E 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 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;
  /* 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.

ActionGesture
Enable TalkBackSettings > Accessibility > TalkBack (or hold both volume keys)
Navigate nextSwipe right
Navigate previousSwipe left
Activate elementDouble tap
ScrollTwo-finger swipe
Open local context menuSwipe up then right
Read from topSwipe down then up
Stop speakingTwo-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.

ActionGesture
Enable VoiceOverSettings > Accessibility > VoiceOver (or triple-click home/side button)
Navigate nextSwipe right
Navigate previousSwipe left
Activate elementDouble tap
ScrollThree-finger swipe
Open rotorTwo-finger rotate
Read from topTwo-finger swipe up
Stop speakingTwo-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):

GestureRequired Alternative
Swipe to deleteDelete button or menu option
Pinch to zoomZoom buttons (+/-)
Pull to refreshRefresh button
Long pressContext menu button
Multi-finger gesturesSingle tap alternatives
Drag and dropMove 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

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
Not ApplicableCriterion doesn’t applye.g., no audio content = audio criteria N/A
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 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

  1. Use the official template: Download from ITI VPAT Repository

  2. Audit against WCAG 2.2 AA: Test every criterion, document findings

  3. 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.
  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. 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: /accessibility or /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.

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

  1. Install axe DevTools browser extension (Chrome/Firefox/Edge)
  2. Install WAVE browser extension as a second opinion
  3. 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. Document your baseline: How many Critical, Serious, Moderate, and Minor issues?
  4. 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