Why accessibility matters
About 15% of people worldwide live with some form of disability. When a checkout flow can't be completed with a keyboard, or a form has no labels for a screen reader, those users leave. Quietly.
The legal aspect has caught up. The ADA has been enforcing equal access to digital services in the US for years. In Europe, the European Accessibility Act went into effect on June 28, 2025.
Websites serving EU users now need to comply with WCAG 2.2 Level AA, and enforcement has already begun. In France, disability organizations filed court proceedings against major retailers within months of the deadline. Germany set fines up to €100,000 per violation.
Accessible design also improves the experience for people who don't identify as having a disability. Sufficient contrast helps someone using a laptop when the sun is hitting the screen. Keyboard navigation helps someone with a broken trackpad.
What WebAIM found in 2025
The 2025 WebAIM Million report found that 94.8% of the top million home pages have detectable WCAG failures. That's down slightly from 95.9% the year before. The average page has 51 errors.
One number from the report surprised me: pages using ARIA averaged 57 errors, while pages without it averaged 27. Incorrect ARIA implementations cause more problems than they solve.
50% of individuals with disabilities say they've seen no improvement in web accessibility over time. I kept coming back to that one while putting this article together.
Introducing the tools
Cypress is a JavaScript-based end-to-end testing framework for modern web apps. It runs directly in the browser and handles waits automatically. Cypress has a command cy.press(), which dispatches native keyboard events like Tab and Escape directly to the browser. That matters for accessibility testing because keyboard navigation is one of the first things WCAG checks require.
Axe-core is an accessibility testing engine built by Deque Systems. It evaluates DOM elements against WCAG rules and returns a structured list of violations with severity levels and references to specific guidelines.
The cypress-axe plugin connects the two. It lets you call cy.checkA11y() inside any Cypress test to run an axe-core scan at that exact moment in the DOM lifecycle, after a page load or after a modal opens. The checks live right next to the interactions that create the DOM state you want to test. The plugin's API hasn't changed in over a year, so the examples below should work on any recent version.
Installation and setup
Three packages, one import:
npm install --save-dev cypress axe-core cypress-axe
Import the plugin into your Cypress support file:
// cypress/support/e2e.js
import 'cypress-axe';
After loading any page, inject axe-core:
cy.visit('/');
cy.injectAxe();
Your first test can be a few lines:
it('home page has no detectable violations', () => {
cy.visit('/');
cy.injectAxe();
cy.checkA11y();
});
Basic usage patterns
Before jumping into full examples, here are a few patterns that have held up over time.
Scope your checks
You don't need to scan the entire page after every interaction. For modals and forms, scoping the check to the relevant element reduces noise and runs faster:
cy.checkA11y('[role="dialog"]');
cy.checkA11y('form');
Filter by severity
A common adoption strategy: fail the build only on critical violations at first. Once you fix the critical violations, you can move to the serious ones.
cy.checkA11y(null, { includedImpacts: ['critical'] });
Scope to specific WCAG tags
If you need to check against a specific standard, you can pass runOnly with WCAG tags:
cy.checkA11y('.example-class', {
runOnly: {
type: 'tag',
values: ['wcag2a'],
},
});
Log without failing
During initial adoption, when you want visibility but aren't ready to block the pipeline, the fourth argument tells cypress-axe to log violations without failing the test:
cy.checkA11y(null, null, null, true);
Retry for async content
Single-page apps sometimes aren't fully rendered when the check runs. You can configure retries:
cy.checkA11y(null, {
retries: 3,
interval: 100,
});
Custom logging that makes violations readable
The default failure output from cypress-axe is not helpful in a terminal. A small callback function changes that completely.
Here's the one I've been using. It goes at the top of your spec file or in a shared utility:
function terminalLog(violations) {
cy.task(
'log',
`${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} ${violations.length === 1 ? 'was' : 'were'} detected`
);
const violationData = violations.map(
({ id, impact, description, nodes }) => ({
id,
impact,
description,
nodes: nodes.length,
})
);
cy.task('table', violationData);
}
You need to register the task handlers in cypress.config.js, inside your setupNodeEvents function:
module.exports = {
e2e: {
setupNodeEvents(on, config) {
on('task', {
log(message) {
console.log(message);
return null;
},
table(message) {
console.table(message);
return null;
},
});
},
},
};
Then pass the callback as the third argument:
cy.checkA11y(null, null, terminalLog);
Instead of a long, confusing list of text, you get a clear console table that shows each violation ID, its severity, a short description, and the number of nodes affected. The first time I ran it, the output showed color-contrast as serious (75 nodes) and select-name as critical (1 node). The select dropdown got fixed that same week.
Example 1: E2E test with real API calls
This example uses a weather app that calls a live API. The test types a city name, waits for search results, selects a city, verifies the weather display updates, and then runs an accessibility check on the final state.
describe('E2E: city search + accessibility', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('checks accessibility after a search', () => {
cy.intercept('/search?q=*').as('search');
cy.intercept('/weather?*').as('weather');
cy.get('[data-cy="city-selector"]').type('Toronto');
cy.wait('@search').then((interception) => {
expect(interception.response.statusCode).to.eq(200);
});
cy.get('#city-select').select(1);
cy.wait('@weather').then((interception) => {
expect(interception.response.statusCode).to.eq(200);
const cityName = interception.response.body.name;
cy.get('[data-cy="weather-display"]')
.should('be.visible')
.and('contain', cityName);
});
cy.checkA11y(null, null, terminalLog, true);
});
});
Example 2: Scoped checks for modals and dynamic content
Modals and form error states are where I've seen the most accessibility regressions. The content appears dynamically, and ARIA attributes on dialogs are the kind of thing that works fine until someone changes the markup.
Scoping the check to the modal DOM avoids noise from the rest of the page:
describe('Weather app modal', () => {
beforeEach(() => {
cy.visit('http://localhost:8080/');
cy.injectAxe();
});
it('checks modal accessibility', () => {
cy.contains('button', 'Open Modal').click();
cy.get('[data-cy="modal-overlay"]').should('be.visible');
cy.checkA11y('[data-cy="modal-overlay"]', null, terminalLog, true);
});
});
That scoped selector tells axe to evaluate only the modal, not the entire page behind it. It catches missing aria-modal and contrast problems inside the dialog.
For forms, the same idea applies: check accessibility both before and after triggering validation errors.
Example 3: Keyboard navigation with cy.press()
Cypress added the cy.press() command, which sends native keyboard events to the browser. This is useful for testing focus management, which is one of the most common accessibility failures and one that cy.checkA11y() can't catch on its own.
describe('Modal keyboard navigation', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('focuses inside the modal and closes on Escape', () => {
cy.contains('button', 'Open Modal').click();
cy.get('[data-cy="modal-overlay"]').should('be.visible');
// focus should start on the first focusable element inside the modal
cy.focused().should('have.attr', 'data-cy', 'modal-close-btn');
// Tab through the modal, focus should stay inside
cy.press('Tab');
cy.focused().should('be.within', cy.get('[data-cy="modal-overlay"]'));
// Escape should close the modal
cy.press('Escape');
cy.get('[data-cy="modal-overlay"]').should('not.exist');
// focus should return to the trigger button
cy.focused().should('contain', 'Open Modal');
// run axe after the modal closes
cy.checkA11y(null, null, terminalLog, true);
});
});
This test checks four things that axe-core can't: that focus moves into the modal on open, that Tab doesn't escape the modal boundary, that Escape closes the dialog, and that focus returns to the button that triggered it. Those four behaviors are all WCAG 2.4.3 (Focus Order) requirements.
Example 4: Storybook integration
If your team maintains a component library, Storybook is a natural place to catch accessibility problems before they spread into applications. The test visits the Storybook iframe URL for each component story:
describe('Storybook: CitySelector', () => {
beforeEach(() => {
cy.visit(
'http://localhost:6006/iframe.html?id=components-cityselector--default'
);
cy.injectAxe();
});
it('has no accessibility violations', () => {
cy.checkA11y(null, null, terminalLog, true);
});
});
Each story gets its own scan. Missing labels and incorrect ARIA roles show up at the component level.
Example 5: Component tests
Cypress component testing mounts a React, Vue, Angular, or Svelte component directly in the test runner. No dev server, no full application. The feedback loop is under a second.
import CitySelector from './CitySelector';
describe('<CitySelector />', () => {
beforeEach(() => {
cy.injectAxe();
});
it('renders with no critical violations', () => {
cy.mount(<CitySelector />);
cy.checkA11y(
null,
{ includedImpacts: ['serious', 'critical'] },
terminalLog,
true
);
cy.get('[data-cy="city-selector"]').should('be.visible');
});
});
This is the closest you can get to catching problems while you're still building the component. If the label is missing here, it will be missing everywhere it's used. You can combine this with visual regression and snapshot tests for a broader check without adding another tool.
The part automation can't do
Automated scanners catch approximately 30 to 50 percent of accessibility issues. They find missing alt text and broken ARIA references. They can't tell you whether alt text is meaningful or whether a screen reader user would understand your page's reading order.
Dynamic content in single-page apps can also cause false negatives. If the page hasn't finished rendering when axe runs, it'll miss real problems or flag temporary states. Wait for your app's "ready" signal:
cy.get('.loading-spinner').should('not.exist');
cy.get('[data-loaded="true"]').should('exist');
cy.checkA11y();
Manual checks remain necessary. Tab through the page and run a screen reader (NVDA on Windows, VoiceOver on macOS) before each release. Ten minutes of that catches things no scanner will find.
If you're already on Cypress Cloud
Everything in this article uses the free, open-source cypress-axe plugin. But if your team already records test runs to Cypress Cloud with Test Replay, there's a paid add-on called Cypress Accessibility that takes a different approach.
There is no setup. No cy.injectAxe(), no cy.checkA11y(), no callback functions. It runs axe-core server-side against every DOM state your tests already capture. The accessibility checks happen after the test finishes, so they don't slow down your test suite or introduce flakiness.
What you get is a dashboard in Cypress Cloud that groups violations by page and component. Each violation links to a full DOM snapshot where you can inspect the element in context. You can compare two runs side by side to see only the new issues introduced by a specific commit. And there's a Results API you can call from your CI pipeline to fail a build based on your own severity thresholds.
The feature I found most useful was the run comparison. When reviewing a pull request, I could see whether the PR introduced new violations or if they were pre-existing. That made code review conversations about accessibility much shorter.
It also has Jira integration for filing violations directly as tickets, and configurable profiles so you can apply different rules to different branches or teams.
The free cypress-axe approach from this article gives you full control and works anywhere Cypress runs. Cypress Accessibility is worth looking at if your team already pays for Cypress Cloud and wants reporting without maintaining custom logging code.
One thing Cypress Accessibility does that axe-core cannot: it watches how your tests actually interact with elements on the page. Cypress added a custom rule called "Interactive elements should be semantically correct" that flags elements your tests click or type into when those elements use the wrong semantic markup. A div with an onClick handler that behaves like a button, or an image used as a tooltip trigger. These pass every axe-core scan because axe-core evaluates the DOM as it is. Cypress Accessibility evaluates how the DOM is being used, which is how it catches problems that static scans miss entirely.
For a deeper comparison of the two approaches, the Cypress team published a post called "In-test" vs "out-of-test" accessibility automation that goes into the tradeoffs in detail.
Resources
- Axe Core documentation (Deque)
- Cypress accessibility guide
- WCAG 2.2 quick reference (W3C)
- cypress-axe plugin (npm)
- Open source accessibility plugins for Cypress
The code examples in this article use a weather app I built for testing. If you want to go deeper on Cypress, my book Ultimate Web Automation Testing with Cypress covers the framework end-to-end.
Find me on LinkedIn.



Top comments (0)