Skip to content

Accessibility

How to catch accessibility issues during development

Lint JSX for accessibility, run axe in component and Playwright tests, gate CI on violations, and know which problems still need a person.

Your UX Agent teamProduct
17 min read

To catch accessibility issues during development, check in four layers. Lint your JSX as you write it. Run axe-core on rendered components in your unit tests. Run axe-core and keyboard assertions on pages in your end-to-end tests. Then check the deployed site, with its real content.

Each layer sees something the one before it cannot, and none sees everything. A rule can establish that a button has no name. It cannot tell you whether a name makes sense, or what a screen reader user hears.

Fixing issues with a coding agent starts by finding them. This guide covers that stage, before the code ships: working config for each layer, what each one missed when we ran it, a CI job, and the lines that make a coding agent run the checks before it hands work back.

We ran the lint, component and page examples on 2 November 2026, with eslint-plugin-jsx-a11y 6.10.2, Vitest 3.2 with jsdom, axe-core 4.13 and Playwright 1.62. Check the versions when you copy them.

The four layers at a glance

  • Lint (eslint-plugin-jsx-a11y), in the editor and on every commit. It catches markup you can see in the source: an image without alt, a click handler on a div, a label tied to no field, an ARIA attribute that does not exist. It cannot see what renders at runtime.
  • Rendered components (axe-core in Vitest or Jest), with your unit tests. It catches missing names and invalid ARIA in the HTML a component actually renders, in each state you put it in. In jsdom it cannot measure anything that needs layout, such as contrast.
  • Pages in a browser (Playwright with @axe-core/playwright), on every pull request. It catches what axe-core can decide on a real page, contrast and target size included, at the screen widths and in the states you test, plus the keyboard behaviour you assert. It sees only the pages and states your tests reach.
  • The deployed site, after each deploy and on a schedule. It catches what real content, production styles and third-party scripts add. Like the others, it cannot judge whether the page works for a person.

All four miss the same things: whether alt text and labels describe what they should, whether focus order follows the task, and what assistive technology announces. We list, criterion by criterion, what automated rules check and what a person still has to.

Layer 1: lint JSX as you write it

eslint-plugin-jsx-a11y describes itself as a “Static AST checker for accessibility rules on JSX elements”. It reads your source, not your page, so it runs in the editor and costs nothing to repeat. Install it with ESLint and, for TypeScript, the typescript-eslint parser:

eslint.config.js: jsx-a11y’s recommended rules for TSX and JSX files, JavaScript
// eslint.config.jsimport jsxA11y from 'eslint-plugin-jsx-a11y';import tseslint from 'typescript-eslint';​export default [  {    files: ['src/**/*.{jsx,tsx}'],    ...jsxA11y.flatConfigs.recommended,    languageOptions: {      ...jsxA11y.flatConfigs.recommended.languageOptions,      parser: tseslint.parser,    },    settings: {      'jsx-a11y': {        // Check your own components as the elements they render        components: { Button: 'button', TextField: 'input', Link: 'a' },      },    },    rules: {      ...jsxA11y.flatConfigs.recommended.rules,      // In neither of the plugin's shipped configs      'jsx-a11y/no-aria-hidden-on-focusable': 'error',      'jsx-a11y/prefer-tag-over-role': 'error',    },  },];

Three details matter. The plugin’s configs set no files, so you scope them yourself. The components map tells the rules that your Button is a button. Without it, rules that depend on the element, such as those for alt text, labels, links and click handlers, skip your own components. Only rules that read an attribute on its own still see them: the checks for valid ARIA attributes, values and roles, for autoFocus, and for a positive tabIndex. And two useful rules, no-aria-hidden-on-focusable and prefer-tag-over-role, are in neither the recommended nor the strict config, so the example turns them on.

On ESLint versions: the plugin’s current release, 6.10.2, declares support up to ESLint 9, and ESLint 9 reached end of life on 6 August 2026. npm refuses to install the plugin beside ESLint 10 unless you pass --legacy-peer-deps. With that flag, ESLint 10.14 ran this config on our test file with the same results as ESLint 9, but the plugin does not promise it. The flag also makes npm ignore typescript-eslint’s TypeScript range, so pin TypeScript 6: typescript-eslint supports nothing newer and stops with an error on TypeScript 7. Check the plugin’s releases before you upgrade.

We ran it on a sign-up component with problems planted in it. It reported the image without alt, the div with a click handler, the label tied to no field, the link with href="#", and, with the extra rule, aria-hidden="true" on a focusable button.

Layer 2: test rendered components

A component test renders the real output, so axe-core can check the HTML instead of the source. Call axe-core directly on the rendered container; this avoids a wrapper package and works the same in Vitest and Jest. Configure Vitest for it:

vitest.config.ts: jsdom, automatic clean-up, and only the component tests, TypeScript
// vitest.config.tsimport { defineConfig } from 'vitest/config';​export default defineConfig({  test: {    environment: 'jsdom',    // Lets Testing Library clean up between tests    globals: true,    // Without this, Vitest also collects the Playwright specs in e2e/ and fails on them    include: ['src/**/*.test.{ts,tsx}'],  },});

If you keep a single vite.config.ts instead, put the same test block there and make /// <reference types="vitest/config" /> its first line, so TypeScript accepts it.

src/components/SignupForm.test.tsx: axe-core on a rendered form, as loaded and after an empty submit, TSX
// src/components/SignupForm.test.tsximport { render, fireEvent } from '@testing-library/react';import axe from 'axe-core';import { expect, test } from 'vitest';import { SignupForm } from './SignupForm';​async function axeViolations(container: Element) {  const results = await axe.run(container, {    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] },    // jsdom has no layout, so these two rules cannot measure anything here    rules: { 'color-contrast': { enabled: false }, 'target-size': { enabled: false } },  });  return results.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`);}​test('sign-up form as rendered', async () => {  const { container } = render(<SignupForm />);  expect(await axeViolations(container)).toEqual([]);});​test('sign-up form after an empty submit', async () => {  const { container, getByRole } = render(<SignupForm />);  fireEvent.click(getByRole('button', { name: 'Sign up' }));  expect(await axeViolations(container)).toEqual([]);  // No axe-core rule checks that the error is tied to the field  getByRole('textbox', { name: 'Email', description: 'Enter your email address' });});

The second test is the reason to test components at all: it puts the form in a state the page does not load in. On a version of the form whose submit button held only an icon, the first test failed with button-name: button. The last line is a query, not an axe-core rule. Testing Library throws when no textbox has that name and description, so it fails on a form whose error message is not tied to its field, which axe-core passed. Shown only under the field, the message reaches screen reader users only if they go looking for it; aria-describedby makes it part of what they hear at the field. Our Claude Code guide walks through that fix for an error message not tied to its field, under 1.3.1 Info and Relationships.

If your components live in Storybook, its accessibility addon runs axe-core on each story. When you run stories as tests, through its Vitest addon for example, set parameters.a11y.test to 'error' to fail the run on a violation; 'todo' only shows warnings. Vitest’s browser mode, too, runs tests in a real browser, where axe-core can compute contrast.

Older guides, including web.dev’s React audit article, suggest react-axe to log violations in the browser console. Its successor, @axe-core/react, says it does not support React 18 and above.

Layer 3: test pages in a browser

Playwright runs your pages in a real browser, at the screen widths you choose, so axe-core can measure contrast and target size there. Playwright’s own accessibility guide is clear about the limit: automated tests “can detect some common accessibility problems such as missing or invalid properties”, and many problems need manual testing.

Put the axe-core setup in a fixture, as Playwright’s guide suggests, and write each failure so a person or an agent can act on it without reading a JSON dump:

e2e/axe.ts: a shared AxeBuilder and a readable failure message, TypeScript
// e2e/axe.tsimport { test as base } from '@playwright/test';import AxeBuilder from '@axe-core/playwright';import type { Result } from 'axe-core';​const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];​export const test = base.extend<{ axe: () => AxeBuilder }>({  axe: async ({ page }, use) => {    await use(() => new AxeBuilder({ page }).withTags(WCAG_TAGS));  },});export { expect } from '@playwright/test';​// One block per rule: what failed, where, and the WCAG tags axe-core gives itexport function describe(violations: Result[]): string {  return violations    .map((v) => [      `${v.id} (${v.impact}): ${v.help}`,      `  tags: ${v.tags.filter((tag) => tag.startsWith('wcag')).join(', ')}`,      ...v.nodes.map((node) => `  element: ${node.target.join(' ')}`),    ].join('\n'))    .join('\n\n');}
e2e/a11y.spec.ts: pages as loaded, and the sign-up form’s error state, TypeScript
// e2e/a11y.spec.tsimport { test, expect, describe } from './axe';​for (const path of ['/', '/pricing', '/signup']) {  test(`${path} as loaded`, async ({ page, axe }, testInfo) => {    await page.goto(path);    const results = await axe().analyze();    await testInfo.attach('axe-results.json', {      body: JSON.stringify(results.violations, null, 2),      contentType: 'application/json',    });    expect(results.violations, describe(results.violations)).toEqual([]);  });}​test('/signup after an empty submit', async ({ page, axe }) => {  await page.goto('/signup');  await page.getByRole('button', { name: 'Sign up' }).click();  await expect(page.getByText('Enter your email address')).toBeVisible();  const results = await axe().analyze();  expect(results.violations, describe(results.violations)).toEqual([]);  // No axe-core rule checks that the error is tied to its field, so assert it  await expect(page.getByLabel('Email')).toHaveAccessibleDescription('Enter your email address');});
playwright.config.ts: the dev server locally, a deployment when BASE_URL is set, at two widths, TypeScript
// playwright.config.tsimport { defineConfig } from '@playwright/test';​export default defineConfig({  testDir: 'e2e',  reporter: 'html',  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:5173' },  // Start the dev server unless BASE_URL points at a deployment  webServer: process.env.BASE_URL    ? undefined    : { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI },  projects: [    { name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },    { name: 'narrow', use: { viewport: { width: 320, height: 640 } } },  ],});

Add "test:a11y": "playwright test e2e/a11y.spec.ts" to your scripts. The wcag22aa tag matters: axe-core’s target-size rule, for 2.5.8 Target Size (Minimum), runs only when you ask for it. The 320px project is the width 1.4.10 Reflow is written around, and some failures, such as a target that shrinks in a mobile layout, appear only there.

When we made the error message grey (#aaa on white) on a test page, the spec failed with this message:

The failure message describe() writes, text
color-contrast (serious): Elements must meet minimum color contrast ratio thresholds  tags: wcag2aa, wcag143  element: #err

That is a finding with its evidence: the rule, the element, the page and the state, ready to hand over one at a time. Below it, Playwright prints the violations in full, including the measured ratio, 2.32:1 against a required 4.5:1, for 1.4.3 Contrast (Minimum). When we left the error message untied from its field, axe-core passed the page and only the last assertion failed.

If you inherit violations you cannot fix this week, Playwright’s guide offers three ways to live with them: disable a rule, exclude an element, or snapshot a fingerprint of each known violation (its rule and its targets). Prefer the fingerprint. Disabling a rule hides every new violation of it, and excluding an element stops all rules on it and everything inside it.

Run the checks in CI

This job follows the GitHub Actions example in Playwright’s CI guide and adds the lint and component steps. Order them cheapest first, so a lint error fails in seconds:

.github/workflows/accessibility.yml: lint, component tests and page tests on each pull request, YAML
# .github/workflows/accessibility.ymlname: Accessibility checkson:  pull_request:  push:    branches: [main]jobs:  checks:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v6      - uses: actions/setup-node@v6        with:          node-version: lts/*      - run: npm ci      - name: Lint        run: npm run lint      - name: Component tests        run: npm run test:components      - name: Install the browser        run: npx playwright install --with-deps chromium      - name: Page tests        run: npm run test:a11y      - uses: actions/upload-artifact@v4        if: ${{ !cancelled() }}        with:          name: playwright-report          path: playwright-report/          retention-days: 30

It assumes "lint": "eslint ." and "test:components": "vitest run" in your scripts. Make the job a required check on the main branch, so a violation blocks the merge rather than starting a discussion.

Layer 4: check the deployed site

Everything above runs against your code and your test data. Your visitors get the deployed site: content from the CMS, real product names and images, production CSS and fonts, and third-party scripts such as chat widgets, consent banners and embedded video. Each can add failures no earlier layer could see, and each template your tests never open is unchecked.

The same spec runs against a deployment. If your host reports deployments to GitHub with an environment URL, this job runs it on every successful one:

.github/workflows/accessibility-deployed.yml: the page tests against each successful deployment, YAML
# .github/workflows/accessibility-deployed.ymlname: Accessibility checks on the deploymenton: deployment_statusjobs:  deployed:    if: github.event.deployment_status.state == 'success'    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v6      - uses: actions/setup-node@v6        with:          node-version: lts/*      - run: npm ci      - run: npx playwright install --with-deps chromium      - name: Page tests against the deployment        run: npm run test:a11y        env:          BASE_URL: ${{ github.event.deployment_status.environment_url }}

Run it on a schedule against production too, because content changes without a deploy. It still covers only the paths in the spec. A full audit goes further: it samples every type of page, complete processes and their states, and adds manual and assistive-technology testing; we walk through how a website accessibility audit chooses and tests its pages.

Make your coding agent run the checks

A coding agent that writes UI can run the same checks before it says it is done, if you tell it to and tell it how. Put this in AGENTS.md:

AGENTS.md: the checks a coding agent runs before it hands back UI work, Markdown
## Accessibility checks​Before you say a UI change is done:​1. Run `npm run lint`. Fix every jsx-a11y error in the files you changed.2. Run `npm run test:components` and `npm run test:a11y`.3. Paste the summary of each run.4. List what these checks cannot confirm for this change: keyboard use,   focus order, wording, and what a screen reader announces.​If a check fails and you cannot fix it, stop and report the failure.Never make a check pass by weakening it: no eslint-disable comments,no disableRules() or exclude() on AxeBuilder, no skipped tests andno edits to expected results, unless the task asks for it.

The last paragraph is there because the quickest way to turn a failing check green is to switch it off, and an agent working towards “tests pass” may take it. Review the diff for exactly those edits.

Cursor reads AGENTS.md. Claude Code reads it when the project has no CLAUDE.md or CLAUDE.local.md; if yours has one, add the line @AGENTS.md to it to import the file. Instructions are context, not enforcement. To make a check run whatever the agent decides, use a hook: we set up a Claude Code hook that lints every JSX or TSX file Claude edits, and a Cursor stop hook that sends failing checks back to Agent.

When CI fails, give the agent one block of the describe() output per task, with the page and the state. One violation per task keeps each diff small enough to review.

What still needs a person

Every layer here checks what a rule or an assertion can decide. A person decides the rest:

  • whether alt text, labels and error messages say something useful;
  • whether the focus order follows the task, and whether custom widgets respond to the keys people expect;
  • what a screen reader announces, in the combinations your visitors use;
  • whether a page that passes every check can actually be used, which only testing with disabled people shows.

This matters more when an agent writes the components. AI-generated components often pass automated checks and still fail keyboard users, because the checks cannot see missing behaviour. Put the mouse away and use each changed control with the keyboard before you merge.

Frequently asked questions

Is eslint-plugin-jsx-a11y enough to catch accessibility issues?

No. It reads the JSX in your source, so it catches things like an image without alt and a click handler on a div. It cannot see computed colours, what a component renders at runtime, or how the page behaves with a keyboard. Use it as the first of several layers.

Can axe-core run in Jest or Vitest?

Yes. Render the component with Testing Library in jsdom and call axe-core on the rendered container. axe-core says its color-contrast rule is known not to work with jsdom, and without layout its target-size rule can report a pass, so turn both off there and check them in a real browser, with Playwright or Vitest’s browser mode.

Should accessibility checks block a merge?

Violations from lint and axe-core, yes: a rule has established them, and they are cheapest to fix before the merge. Results axe-core marks as needing review are not failures. Have a person look at them rather than block on them.

How do I make a coding agent run accessibility checks?

List the commands in the instructions file it reads, such as AGENTS.md, ask it to paste each result, and tell it never to disable a rule or skip a test to make a check pass. Instructions are not enforced, so use a hook where the check must always run.