Testing React Accessibility with Axe: Dev Console, Vitest, and the Chrome Extension
Most React developers don’t test accessibility until a compliance audit forces it. Then they spend two weeks firefighting: missing labels, broken focus traps, colour contrast failures that were always there, just never caught. Axe DevTools finds most of that before it ships. This article covers how actually to use it, not just install it.
Why axe, specifically
Several tools exist for accessibility testing: Lighthouse, WAVE, and IBM Equal Access Checker. Axe runs more rules, produces fewer false positives, and plugs into your test suite. The Deque team built the underlying engine that Lighthouse’s accessibility audit runs on. You’re already getting a subset of axe when you run lighthouse --only-categories=accessibility, so you may as well use the full thing.
The free tier covers WCAG 2.1 A and AA rules. That’s 57 rules. The paid DevTools Pro tier adds WCAG 2.2, best practices, and guided workflows. For most teams, the free tier catches 80% of real issues.
Setting up axe-core in your React project
Two packages matter: axe-core (the engine) and @axe-core/react (a dev-only wrapper that runs axe after every render and logs violations to the browser console).
npm install --save-dev axe-core @axe-core/reactIn your entry file (index.tsx for most projects), add this:
// index.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'if (process.env.NODE_ENV !== 'production') {
const axe = await import('@axe-core/react')
axe.default(React, ReactDOM, 1000)
}ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
That 1000 is a debounce delay in milliseconds. Axe waits 1 second after a render before running, so you don't get flooded with violations during rapid state changes. In practice, 500ms works fine for most apps; 1000ms is safer if you have heavy async rendering.
After this, open your browser console. Every accessibility violation renders as a console.error with the violated rule, the impact level (critical, serious, moderate, minor), and a link to Deque's documentation for that specific rule.
What those console errors actually mean
Output looks like this:
axe: CRITICAL: color-contrast
Elements must have sufficient color contrast
https://dequeuniversity.com/rules/axe/4.8/color-contrast #submit-button
Expected ratio: 4.5:1, Got: 2.8:1Each violation includes the rule ID (like color-contrast or button-name), the impact level (critical and serious are blocking; moderate and minor are real but less immediately damaging), a CSS selector pointing to the exact element, and a link to the Deque docs explaining what to fix.
You don’t need to memorise WCAG. That link does the work.
Writing automated tests with Vitest and axe-core
Console logging in dev is useful for catching things as you build. It’s not a substitute for tests. Running axe-core in Vitest means violations break your CI, and you're not pulling in a wrapper library that hasn't had a meaningful release in years.
npm install --save-dev axe-core @testing-library/react @testing-library/user-eventA basic test for a button component:
// Button.test.tsx
import { render } from '@testing-library/react'
import { axe } from 'axe-core'
import { describe, expect, it } from 'vitest'
import { Button } from './Button'describe('Button', () => {
it('has no accessibility violations', async () => {
const { container } = render(
<Button onClick={() => {}}>Submit</Button>
)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
})
})That test will fail if the button has no accessible name, if contrast is insufficient, or if it’s using a non-interactive element as a button. When it does fail, Vitest prints the full violations array (rule ID, impact level, affected selector, description) in the terminal output. No custom formatter needed.
Testing with different states
Most accessibility bugs live in component states, not default renders. Test them:
import { render } from '@testing-library/react'
import { axe } from 'axe-core'
import { describe, expect, it } from 'vitest'
import { Modal } from './Modal'describe('Modal', () => {
it('has no violations when open', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}}>
<p>Content here</p>
</Modal>
)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
}) it('has no violations when closed', async () => {
const { container } = render(
<Modal isOpen={false} onClose={() => {}}>
<p>Content here</p>
</Modal>
)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
})
})
A modal that passes axe when closed might fail when open because the focus trap is broken, the backdrop doesn’t have aria-hidden, or the dialog lacks an accessible name. Testing both states catches that.
Configuring axe rules
Not every axe rule applies to every project. You can configure which rules run, which elements get skipped, and which impact levels you care about.
Disabling a rule for a single test
const results = await axe(container, {
rules: {
'color-contrast': { enabled: false }
}
})Only do this when you have a documented reason. “Design team said it’s fine” isn’t a documented reason. “This component is only ever rendered on a background that passes contrast separately”.
Running only specific rules
const results = await axe(container, {
runOnly: {
type: 'rule',
values: ['label', 'button-name', 'aria-required-attr']
}
})Useful when you’re doing a focused audit on one class of issue, say after a sprint where you added a lot of form inputs.
Excluding elements
const results = await axe(container, {
exclude: [['#legacy-iframe']]
})That double array is intentional; axe uses CSS selector lists where each item can itself be an array for shadow DOM traversal.
Common violations and how to actually fix them
Missing form labels
This is the most common React accessibility bug. A text input with no <label> associated with it.
Bad:
<input type="email" placeholder="Enter your email" />Placeholders disappear when users type. Screen readers don’t reliably announce them as labels. Axe flags this as a label violation with impact: critical.
Fixed with an explicit label:
<label htmlFor="email">Email address</label>
<input id="email" type="email" />Fixed with aria-label when a visible label isn't possible:
<input
type="search"
aria-label="Search products"
placeholder="Search..."
/>Fixed with aria-labelledby when the label already exists elsewhere in the DOM:
<h2 id="section-heading">Contact Information</h2>
<input
type="email"
aria-labelledby="section-heading"
/>Buttons without accessible names
A button that only contains an icon. You see this constantly on icon-only toolbars.
Bad:
<button onClick={handleClose}>
<XIcon />
</button>Axe flags this as button-name, impact: critical. A screen reader announces "button" with no context about what it does.
Fixed:
<button onClick={handleClose} aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>The aria-hidden="true" on the icon tells screen readers to skip it. The button's accessible name comes from aria-label, not from any text inside.
Color contrast failures
WCAG AA requires a 4.5:1 contrast ratio for normal text, 3:1 for large text (18pt or 14pt bold). Axe checks this at render time using computed styles.
When you get a color-contrast violation, the axe output tells you the actual ratio and the required ratio. No guessing needed; use the Deque color contrast checker or your design tool's built-in checker to find a passing color.
A pattern that trips people up: contrast that passes in light mode fails in dark mode, and axe only tests whatever is rendered. Test both themes explicitly.
import { render } from '@testing-library/react'
import { axe } from 'axe-core'
import { describe, expect, it } from 'vitest'
import { Button } from './Button'describe('Button contrast', () => {
it('passes contrast in light mode', async () => {
document.documentElement.setAttribute('data-theme', 'light')
const { container } = render(<Button>Submit</Button>)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
}) it('passes contrast in dark mode', async () => {
document.documentElement.setAttribute('data-theme', 'dark')
const { container } = render(<Button>Submit</Button>)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
})
})
Focus management in modals
Axe won’t catch every focus management bug (some require interaction testing), but it catches the structural ones: a dialog element without role="dialog", a modal that renders without aria-modal="true", a missing aria-labelledby pointing to the dialog's heading.
A correct modal structure in React:
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const titleId = useId() if (!isOpen) return null return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div role="document">
<h2 id={titleId}>{title}</h2>
{children}
<button onClick={onClose} aria-label="Close dialog">
✕
</button>
</div>
</div>
)
}
useId() (available since React 18) generates stable IDs that work in SSR. Don't use Math.random() or manual counters for accessible IDs; they break hydration and create duplicate ID violations.
Using axe with React Testing Library
RTL and axe pair well because RTL already encourages testing from the user’s perspective. After user interactions, re-run Axe to catch violations that only appear in specific states.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'axe-core'
import { describe, expect, it } from 'vitest'
import { Accordion } from './Accordion'describe('Accordion', () => {
it('has no violations on initial render', async () => {
const { container } = render(
<Accordion items={[
{ id: '1', title: 'Section 1', content: 'Content here' }
]} />
)
const results = await axe(container)
expect(results.violations).toHaveLength(0)
}) it('has no violations after expanding an item', async () => {
const user = userEvent.setup()
const { container } = render(
<Accordion items={[
{ id: '1', title: 'Section 1', content: 'Content here' }
]} />
) await user.click(screen.getByRole('button', { name: 'Section 1' }))
const results = await axe(container)
expect(results.violations).toHaveLength(0)
})
})
Render, interact, re-assert. Dynamic components like accordions, tabs, and comboboxes have different ARIA states after interaction. aria-expanded needs to be updated. aria-selected needs to change. aria-controls needs to point to a real element that's now visible. Axe checks all of that.
The axe DevTools Chrome extension
Vitest tests components in isolation. You also need to test assembled pages, because components that pass individually can still conflict when composed together. Two components with duplicate IDs. A landmark region that contains another landmark region incorrectly. A skip link pointing to an anchor that doesn’t exist in the rendered DOM.
Get Emmanuel Chilaka’s stories in your inbox
Join Medium for free to get updates from this writer.
That’s where the axe DevTools Chrome extension comes in. It runs against your app as a real user would see it, in a real browser, with actual computed styles, real network-loaded fonts, and whatever JavaScript state exists at scan time.
Installing it
Search “axe DevTools” in the Chrome Web Store and install it. After installation, reload any tab you want to audit. The extension adds an “axe DevTools” tab inside Chrome DevTools. Open DevTools with F12 or Cmd+Option+I, then click the axe tab.
You don’t need to create an account for the free tier. The free version runs all WCAG 2.1 A and AA rules. The paid Pro version adds WCAG 2.2 rules, a guided testing workflow, and the ability to save and share reports across a team.
Running your first scan
Navigate to your React app’s home page (or whichever route you want to audit first). Open DevTools, go to the axe tab, and click Scan ALL of my page. For a mid-sized React app, the scan takes 2–5 seconds.
What comes back is a list of violations sorted by impact: critical at the top, then serious, moderate, and minor. Each row shows the rule name, a short description, and how many elements on the page triggered it.
Click any violation to expand it. You get the issue tags (which WCAG criteria apply), the element selector you can copy straight into your codebase, a plain-English description of the problem, concrete instructions for fixing it, and a link to Deque University’s full documentation for that rule.
The Highlight button
Next to each violation, there’s a Highlight button. Click it, and the extension draws a red outlined box directly on the page around the element that failed. For pages with a lot going on, this is the fastest way to locate a problem, especially for things like a missing label on an input buried inside a card inside a tab panel inside a modal.
When you have multiple violations of the same rule (say, 12 inputs without labels), the extension lists each one separately. Highlight each in turn to work through them.
Testing React-specific states
This is where most developers stop too early. They scan the home page, fix what’s there, and call it done. But React apps are stateful. The home page scan doesn’t cover:
- A modal that renders after clicking a button
- Form validation errors that appear after submission
- A dropdown menu that opens on hover
- A tab panel that switches content
- A toast notification that appears after an async action
For each of these, you need to trigger the state first, then scan.
Example: testing a modal
Open your app. Trigger the modal (click whatever button opens it). With the modal open and visible in the browser, switch to DevTools and run a fresh scan. Axe sees what’s currently rendered, so if the modal is open, it scans the modal’s DOM, including the dialog role, the aria-labelledby, and the focus trap structure.
Then close the modal. Run the scan again. You’re checking that the closed state doesn’t leave orphaned ARIA attributes or broken references in the DOM.
Example: testing form errors
Load a form page. Scan it in its empty initial state. Submit the form without filling it in. Now scan again; form validation errors should be visible. Axe checks whether those error messages are associated with their inputs via aria-describedby, whether the error container has the right role, and whether anything is announced incorrectly.
This two-pass approach (before state change, after state change) is the equivalent extension of what your Vitest axe tests do with stateful component tests. Both matter.
What the extension catches that Vitest tests miss
Vitest runs in jsdom, which doesn’t compute real styles. So contrast violations from CSS variables, theme overrides, or dynamically applied class names slip through. A component can pass with hardcoded colors in jsdom and fail in Chrome the moment a parent component overrides those colors. The extension catches that; your tests don’t.
Same problem with focus indicators. The focus-visible rule checks whether interactive elements have a visible focus state, but jsdom doesn't render:focus-visible pseudo-class styles. If someone stripped the outline: none from buttons to clean up the default browser chrome and forgot to add a replacement, the extension flags it. Vitest never will.
Page-level landmark structure is another blind spot. Your <header>, <main>, <nav>, and <footer> elements create regions that screen reader users navigate by. Duplicate <main> elements, an <nav> without an accessible name when multiple navs exist, and content sitting outside any landmark: these only appear when the whole page is assembled. No component test catches them because no component test sees the full page.
Third-party widgets are invisible to your test suite entirely. Chat bubbles, embedded video players, cookie consent banners: the extension scans all of it regardless of origin. And because it runs in a real browser with real network-loaded fonts, contrast ratios reflect actual rendering. Web fonts affect how text looks at computed sizes in ways jsdom simply doesn’t model.
Filtering and prioritising results
After a scan of a large app, you might have 40 violations. Don’t try to fix everything at once.
Use the Impact filter (top of the violations panel) to show only critical violations first. Fix those. Run the scan again. Move to the serious. This sequence keeps you focused on what actually blocks users rather than getting lost in moderate issues while critical ones stay unfixed.
Best Practices is a separate filter from WCAG violations. These are recommendations that aren’t strict WCAG requirements but improve the experience. Things like redundant alt text (“image of…”), or a page title that doesn’t describe the current content. Worth fixing, but after the actual violations.
Saving and sharing results
Free tier: You can export the scan results as a CSV from the extension panel. Useful for putting numbers in a Jira ticket or sharing with a designer.
Pro tier: results can be saved to a shared project, compared across scans over time, and assigned to team members. If you’re running an accessibility sprint across a whole product, the Pro tier’s saved reports are genuinely useful for tracking which violations got fixed and which ones were deferred.
Using the extension alongside your local dev server
It works against localhost the same way it works against production, which means you can run your Vite or Next.js dev server, open http://localhost:3000, trigger the states you care about, and scan before committing anything.
This is the workflow that catches the most bugs earliest: write the component, check it in the browser with the extension, fix violations, then write the Vitest test to lock that behaviour in. The extension gives you immediate visual feedback; the test prevents regressions.
One thing to watch: if you’re running @axe-core/react in dev mode (the console integration from earlier in this article), you'll see violations in two places simultaneously, the console and the extension panel. They're running the same engine, so the results should match. If they don't, it usually means the component rendered differently between the console scan and the extension scan, which itself tells you something about an inconsistent state.
Running axe tests in CI
Add a dedicated script to your package.json:
{
"scripts": {
"test:a11y": "vitest run --reporter=verbose src/**/*.a11y.test.tsx"
}
}Naming files .a11y.test.tsx keeps them separate from unit tests and lets CI run accessibility checks as a distinct step. Vitest runs tests in parallel by default; axe tests are slow enough that --pool=forks is worth trying if you see flakiness, though most projects won't need it.
Live regions for dynamic content
Screen readers only announce content changes when something tells them to. If you’re rendering toast notifications, loading states, or error messages that appear after user actions, you need aria-live regions.
Axe can’t catch missing live regions (it has no way to know you intended something to be announced), but it does catch badly configured ones.
A reusable live region component:
interface LiveRegionProps {
message: string
politeness?: 'polite' | 'assertive'
}export function LiveRegion({ message, politeness = 'polite' }: LiveRegionProps) {
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
)
}Use polite for non-urgent updates (form saved, item added to cart). Use assertive for errors that block progress. aria-atomic="true" tells screen readers to announce the whole region's content when any part changes, not just the changed part.
sr-only visually hides it but keeps it in the accessibility tree. Don't use display: none or visibility: hidden; those remove elements from the accessibility tree entirely.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}The limits of automated testing
Axe catches around 30–40% of WCAG issues automatically. The rest require human judgment: Is the reading order logical? Does the focus order make sense? Are error messages actually useful to someone who can’t see the form field they describe?
Automated testing is the floor, not the ceiling. Run axe in dev, in Vitest, in CI; then also use a screen reader. VoiceOver on macOS (Cmd + F5) and NVDA on Windows are free. Turn on VoiceOver, close your eyes, and try to complete a user flow using only the keyboard and audio. You’ll find things no tool finds.
Automated scanning plus manual screen reader testing catches most real issues. Catching them before they ship is cheaper than fixing them after a user reports them or a lawyer sends a demand letter. The WCAG audit market exists because companies waited.
Quick reference: axe rules that fire most often in React apps
Getting started today
If you haven’t touched accessibility yet, do this in order:
- Install
@axe-core/reactand add it toindex.tsx. Open the browser console. See what you're already breaking. - Install
axe-coreand write one Vitest test per component (expect(results.violations).toHaveLength(0)). Fix what breaks. - Install the Axe browser extension. Scan your app’s main flows as a logged-in user. Fix critical and serious violations first.
- Hook the Vitest suite into CI. Block merges that introduce new violations.
You won’t get to zero violations immediately. That’s fine. The goal in the first week is to know your actual number, not pretend it’s zero.
References
- axe-core GitHub repository — the open-source engine powering all axe tooling
- @axe-core/react on npm — a dev-only wrapper that logs violations to the browser console after every render
- axe DevTools Chrome Extension — free browser extension for auditing full pages
- Deque University axe Rules — full documentation for every axe rule, including impact level, WCAG mapping, and fix guidance
- Vitest — test runner used for axe-core component tests in this article
- @testing-library/react — used alongside axe-core for rendering components in tests
- WCAG 2.1 Guidelines — W3C specification that axe’s A and AA rules are based on
- WebAIM Contrast Checker — a quick tool for verifying color contrast ratios when fixing
color-contrastviolations - NVDA Screen Reader — free Windows screen reader for manual accessibility testing






