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:
- Automated Testing: Embedding
axe-coreinto our unit and integration tests using Jest and React Testing Library, and enforcing it inside our CI/CD pipeline. - 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:
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
Tabkey. 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 updatingaria-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 (
collapsedorexpanded)? - 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 properaria-activedescendantpointer 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-axeand 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.