All posts
16 Sep 2026

Shift-Left Accessibility: Automated and Manual Testing Strategies for Modern React Apps

Learn how to achieve rock-solid web accessibility by combining automated axe-core CI/CD pipelines with rigorous manual auditing techniques for complex custom React components.

Shift-Left Accessibility: Automated and Manual Testing Strategies for Modern React Apps

Web accessibility (a11y) is too often treated as a final-stage QA checklist item or, worse, an afterthought patched together right before a major release. When we approach accessibility this way, we introduce massive technical debt. Fixing deeply nested DOM structures, broken focus management, and missing ARIA attributes late in the development cycle is costly, frustrating, and prone to regressions.

To build truly inclusive applications, we need to shift-left. This means integrating accessibility guarantees into the earliest phases of development and the core of our automated pipelines.

In this guide, we will explore a two-pronged approach for modern React applications:

  1. Automated Testing: Embedding axe-core into our unit and integration tests using Jest and React Testing Library, and enforcing it inside our CI/CD pipeline.
  2. Manual Component Auditing: Establishing a rigorous manual auditing workflow for complex, highly interactive custom ARIA widgets (like a multi-select combobox) that automated tools simply cannot fully validate.

Part 1: Automated Accessibility Testing in React

Automation cannot catch everything—in fact, automated tools typically only catch about 30% to 50% of all accessibility issues (such as missing alt text, low contrast, or broken form labels). However, automation is exceptional at preventing regressions. By running accessibility checks on every pull request, you ensure that basic structural a11y standards are never accidentally broken.

Setting up Jest, Testing Library, and jest-axe

To test accessibility within our component tests, we can pair React Testing Library with jest-axe, a wrapper around Deque’s industry-standard axe-core engine.

First, install the required testing dependencies:

bash
npm install --save-dev @testing-library/react @testing-library/jest-dom jest-axe

Next, let’s write an accessible button component and its corresponding test suite.

The Component (PrimaryButton.tsx)

import React from 'react';

interface PrimaryButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  isLoading?: boolean;
}

export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
  children,
  isLoading,
  disabled,
  ...props
}) => {
  return (
    <button
      {...props}
      disabled={disabled || isLoading}
      aria-busy={isLoading ? 'true' : 'undefined'}
    >
      {isLoading ? <span aria-hidden="true">Loading...</span> : children}
    </button>
  );
};

The Test (PrimaryButton.test.tsx)

import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { PrimaryButton } from './PrimaryButton';

expect.extend(toHaveNoViolations);

describe('PrimaryButton Accessibility', () => {
  it('should not have any basic accessibility violations', async () => {
    const { container } = render(<PrimaryButton>Submit Form</PrimaryButton>);
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should remain accessible when in a loading state', async () => {
    const { container } = render(<PrimaryButton isLoading>Submit Form</PrimaryButton>);
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

If a developer accidentally removes the accessible text or introduces an improper aria attribute, axe will fail the Jest test with a clear, descriptive breakdown of the WCAG violation.

Integrating Axe into the CI/CD Pipeline

Having tests locally is great, but enforcing them before code hits production is mandatory. Here is a GitHub Actions workflow snippet (.github/workflows/a11y.yml) that runs your test suite, including your jest-axe checks, on every push and pull request:

name: Accessibility CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  accessibility:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Run Unit and A11y Tests
        run: npm test -- --ci --coverage --watchAll=false

Part 2: Manual Auditing for Complex React Components

While axe-core handles static analysis and structural checks effortlessly, it cannot determine whether your custom keyboard navigation feels natural, whether your screen reader announcements make semantic sense, or whether focus trapping behaves correctly inside a modal.

Let’s look at how to manually design, build, and audit a complex custom component: an Accessible Combobox (Autocomplete).

Building the Accessible Combobox

A custom combobox requires precise management of ARIA roles (combobox, listbox, option), focus states, and keyboard events (Arrow Up, Arrow Down, Escape, Enter).

import React, { useState, useRef } from 'react';

interface ComboboxProps {
  items: string[];
  onSelect: (item: string) => void;
}

export const AccessibleCombobox: React.FC<ComboboxProps> = ({ items, onSelect }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  
  const inputRef = useRef<HTMLInputElement>(null);
  const listboxId = 'combobox-listbox';

  const filteredItems = items.filter(item => 
    item.toLowerCase().includes(query.toLowerCase())
  );

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setIsOpen(true);
      setActiveIndex(prev => 
        prev === null || prev >= filteredItems.length - 1 ? 0 : prev + 1
      );
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActiveIndex(prev => 
        prev === null || prev <= 0 ? filteredItems.length - 1 : prev - 1
      );
    } else if (e.key === 'Enter' && activeIndex !== null) {
      e.preventDefault();
      onSelect(filteredItems[activeIndex]);
      setQuery(filteredItems[activeIndex]);
      setIsOpen(false);
      setActiveIndex(null);
    } else if (e.key === 'Escape') {
      setIsOpen(false);
      setActiveIndex(null);
    }
  };

  return (
    <div className="combobox-wrapper">
      <label htmlFor="fruit-search">Choose a fruit</label>
      <div
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-owns={listboxId}
      >
        <input
          id="fruit-search"
          ref={inputRef}
          type="text"
          value={query}
          onChange={e => {
            setQuery(e.target.value);
            setIsOpen(true);
            setActiveIndex(null);
          }}
          onFocus={() => setIsOpen(true)}
          onKeyDown={handleKeyDown}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={
            activeIndex !== null ? `option-${activeIndex}` : undefined
          }
        />
      </div>

      {isOpen && filteredItems.length > 0 && (
        <ul id={listboxId} role="listbox">
          {filteredItems.map((item, index) => {
            const isSelected = activeIndex === index;
            return (
              <li
                id={`option-${index}`}
                key={item}
                role="option"
                aria-selected={isSelected}
                onClick={() => {
                  onSelect(item);
                  setQuery(item);
                  setIsOpen(false);
                  setActiveIndex(null);
                }}
                style={{
                  backgroundColor: isSelected ? '#e2e8f0' : 'transparent',
                }}
              >
                {item}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
};

The Manual Audit Checklist

When reviewing a component like this, automated tools will verify that the aria-* attributes are syntactically valid, but they cannot verify user experience. Walk through this manual testing protocol:

1. Keyboard-Only Navigation Audit

  • Unplug your mouse or push it aside entirely.
  • Focus the input using the Tab key. Does a clear, high-contrast focus ring appear?
  • Type a search term. Does the list open automatically?
  • Press Arrow Down. Does focus move into/through the list items while updating aria-activedescendant?
  • Press Escape. Does the list close without losing focus on the input?
  • Press Enter. Does it select the currently highlighted item, populate the input, and close the listbox?

2. Screen Reader Verification (VoiceOver / NVDA)

  • Turn on your screen reader (VoiceOver on macOS via Cmd + F5, or NVDA/JAWS on Windows).
  • Tab into the combobox input.
  • Listen closely to the announcement: Does it correctly announce the label (“Choose a fruit”), the role (“combobox, editable”), and state (collapsed or expanded)?
  • Type a query. Does the screen reader announce the updated number of suggestions or read the active descendant changes smoothly?

Pro-Tip: If your screen reader stutters or fails to announce state changes, ensure you are utilizing aria-live="polite" regions or proper aria-activedescendant pointer associations to inform assistive technology of dynamic updates.


Summary

Achieving world-class web accessibility in React is not about relying exclusively on magical linters or manual heroics—it is about defense in depth.

  • Automated testing via jest-axe and CI/CD guards your codebase against regression, catching structural markup errors before they leave developer machines.
  • Manual component auditing and keyboard/screen-reader testing validate the lived human experience of users navigating your complex interactive widgets.

By uniting both strategies, you create a robust, scalable workflow that ensures your React applications remain fast, functional, and fundamentally open to everyone.

More posts