Auditing works in three layers: a linter on your JSX, an engine on the rendered accessibility tree, and a human with a keyboard. Each catches what the others cannot. Take a signup card with four defects — a bare <input>, an icon-only <button> holding nothing but an SVG, #a9a9a9 helper text on white, and on line 7 a <div onClick> as the submit button.
The linter runs first because it works on source. Install eslint-plugin-jsx-a11y (6.10.2) and spread jsxA11y.flatConfigs.recommended.rules into eslint.config.js. ESLint 9 39,810 reports the fake button, twice (messages wrapped to fit):
src\Signup.jsx
7:7 error Visible, non-interactive elements with click handlers must have
at least one keyboard listener jsx-a11y/click-events-have-key-events
7:7 error Avoid non-native interactive elements. If using native HTML is not
possible, add an appropriate role... jsx-a11y/no-static-element-interactionsLayer two is axe-core 7,562 , Deque's rules engine (github.com/dequelabs/axe-core (https://github.com/dequelabs/axe-core 7,562 ), 4.13.0, MPL-2.0). It walks a real browser's accessibility tree, so it sees computed color, computed names and ARIA that exists only after a render. @axe-core/playwright wires it into a page; wait for a committed node or you audit an empty root.
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(process.argv[2]);
await page.waitForSelector('button');
const { violations } = await new AxeBuilder({ page }).analyze();
console.log(`${violations.length} violations`);
for (const v of violations)
console.log(`${v.impact}\t${v.id}\t${v.nodes[0].target}\t${v.help}`);Run that against the card, then against a copy with <label htmlFor>, aria-label="Dismiss" and aria-hidden="true" on the icon button, and #595959 helper text:
$ node a11y.js before.html 3 violations critical button-name button Buttons must have discernible text serious color-contrast p Elements must meet minimum color contrast thresholds critical label input Form elements must have labels $ node a11y.js after.html 0 violations
Compare the reports. axe found the three defects the linter missed and said nothing about the <div onClick>: with no role and no name it is indistinguishable from decoration. The linter found only that one. Zero violations means no rule fired — axe-core's README claims 57% of WCAG issues on average, and no engine judges whether aria-label="Click here" is useful.
So run all three: @axe-core/react for findings in the dev console, vitest-axe or jest-axe to fail the build on a regression (Testing React Applications), then unplug the mouse and try a screen reader — NVDA 29,469 , VoiceOver or JAWS 51,382 . The DevTools accessibility pane shows the same tree these printouts came from.