testing and automating accessibility

Configuring eslint-plugin-jsx-a11y for React

Most React codebases have eslint-plugin-jsx-a11y installed and almost none have it configured. The plugin ships thirty-odd rules; the framework preset enables a conservative subset; and — the detail that matters most — none of them see through a design system unless you tell the plugin what your components render. A default install in a component-driven codebase can check almost nothing while reporting a clean run.

This guide covers the configuration that makes it useful. It implements the linting layer described in Accessibility Linting in Editors & Builds.

WCAG Success Criteria Addressed (by prevention):

  • 1.1.1 Non-text Content
  • 2.1.1 Keyboard
  • 3.3.2 Labels or Instructions
  • 4.1.2 Name, Role, Value
Why component mapping decides whether the plugin checks anything Two configurations applied to the same code. Without a components mapping, the plugin sees a capitalised custom element, has no idea what it renders, and skips every rule, so an unnamed icon button passes. With Button mapped to button and Image mapped to img, the plugin applies the same rules it would apply to native elements, and the unnamed icon button is reported. <IconButton icon={<Close />} /> — no visible text, no aria-label No components mapping Plugin sees an unknown element Every DOM-shaped rule is skipped Lint run reports zero problems ✗ silently checks nothing IconButton mapped to button Plugin treats it as a real button control-has-associated-label fires Reported in the editor, on save ✓ catches the missing name In a design system, the mapping is not an optimisation — without it most rules never run at all.

Prerequisites

  • ESLint 9+ with flat config (eslint.config.js); the legacy .eslintrc equivalents are noted where they differ.
  • A React codebase, optionally with Next.js and eslint-config-next already present.
  • A list of the design-system components that render interactive or media elements — buttons, links, inputs, images.

Install and Extend Explicitly

Relying on a framework preset means inheriting its choices silently, including the rules it leaves off. Extend the plugin's own config so the baseline is visible in your repo.

Implementation Guidelines:

  • Install eslint-plugin-jsx-a11y as a direct dev dependency, even if a framework config already pulls it in transitively.
  • Extend jsxA11y.flatConfigs.recommended (or strict if the codebase is new) and then override individual rules.
  • Put the accessibility config after framework configs so your rule choices win.
  • Scope it to the file types that contain JSX, so it does not slow linting of plain modules.
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  // …framework and TypeScript configs first…
  {
    files: ['**/*.{jsx,tsx}'],
    ...jsxA11y.flatConfigs.recommended,
  },
  {
    files: ['**/*.{jsx,tsx}'],
    settings: { 'jsx-a11y': { components: componentMap } },
    rules: {
      /* overrides below */
    },
  },
];

Map Your Components

This is the single highest-value line of configuration in the file. Without it, the plugin ignores anything with a capital letter — which, in a design system, is everything.

Implementation Guidelines:

  • Map every component that renders a native interactive or media element to that element.
  • Keep the map in one exported object so it can be reviewed as a list and updated when the design system grows.
  • Map wrappers too: a Field that renders <input> should be mapped, or label-has-associated-control will not see it.
  • Where a component renders different elements depending on props (as or href), map it to the most constrained case and rely on types for the rest.
// eslint/component-map.js — reviewed like any other API surface.
export const componentMap = {
  Button: 'button',
  IconButton: 'button',
  Link: 'a',
  NavLink: 'a',
  TextField: 'input',
  TextArea: 'textarea',
  Select: 'select',
  Image: 'img',
  Avatar: 'img',
};

The Rules Worth Erroring On

Not every rule earns its place. These are the ones that consistently find real bugs rather than style disagreements.

High-value jsx-a11y rules and what they catch A table of six rules with the bug each prevents. No static element interactions catches a div with a click handler. Click events have key events catches a control that cannot be operated by keyboard. Control has associated label catches an unnamed button or input. Alt text catches images with no alt attribute. Anchor is valid catches a link with no href being used as a button. Aria props and aria props valid value catch invented or mistyped ARIA attributes. Rule The bug it prevents no-static-element-interactions A div with onClick: no role, no keyboard, no name click-events-have-key-events Pointer-only controls that keyboards cannot reach control-has-associated-label Icon buttons and inputs shipped with no name alt-text Images with no alt, announced as a file name anchor-is-valid Links used as buttons, unreachable and unannounced aria-props / aria-proptypes Invented attributes and invalid values, silently ignored

Implementation Guidelines:

  • Set the six rules above to error once their counts are at zero; they have very low false-positive rates.
  • Configure label-has-associated-control with assert: 'either' so both wrapping and htmlFor styles pass.
  • Tune anchor-is-valid to the aspects you care about — with Next.js <Link>, ['invalidHref', 'preferButton'] avoids noise while keeping the real check.
  • Leave no-autofocus on. Autofocus is occasionally justified, and a per-line disable with a reason is the right way to say so.
rules: {
  'jsx-a11y/no-static-element-interactions': 'error',
  'jsx-a11y/click-events-have-key-events': 'error',
  'jsx-a11y/control-has-associated-label': ['error', { depth: 3 }],
  'jsx-a11y/alt-text': ['error', { elements: ['img'], img: ['Image', 'Avatar'] }],
  'jsx-a11y/anchor-is-valid': ['error', { aspects: ['invalidHref', 'preferButton'] }],
  'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
  'jsx-a11y/no-autofocus': ['error', { ignoreNonDOM: true }],
}

Rules That Need Tuning

A handful of rules generate noise in a modern codebase. Turning them off wholesale loses real coverage; configuring them keeps it.

Implementation Guidelines:

  • no-noninteractive-element-to-interactive-role: keep it, but allow the roles your patterns legitimately use, such as option on <li> inside a listbox.
  • no-onchange: obsolete for React and safe to disable — React's onChange is the input event, not the HTML change event.
  • media-has-caption: keep it on, and disable per-line for genuinely silent decorative video with an explanatory comment.
  • heading-has-content: valuable, but it cannot see through a component that renders its children conditionally; pair it with a runtime check.
Four noisy rules and what to do with each Four rules with a verdict each. No noninteractive element to interactive role should be kept but allowed to accept the roles a listbox pattern legitimately needs. No onchange should be turned off because it describes a DOM event React does not use. Media has caption should be kept with per-line exceptions for silent decorative video. Heading has content should be kept, with a runtime check alongside it because the rule cannot see conditional children. no-noninteractive-element-to-interactive-role Keep it, and allow role="option" on li — the listbox pattern requires exactly that. no-onchange Turn it off: React's onChange is the input event, so the rule describes a problem you cannot have. media-has-caption Keep it. Disable per line for genuinely silent decorative video, with the reason in the comment. heading-has-content Keep it, but pair with a DOM test — conditional children are invisible to static analysis.

Handling Next.js Specifics

eslint-config-next enables a jsx-a11y subset with its own opinions, and its <Link> component changes what "a valid anchor" means.

Implementation Guidelines:

  • Keep eslint-config-next for the framework rules, and layer the accessibility config after it.
  • Map Link to a so anchor-has-content and anchor-is-valid apply to routed links.
  • Map Image to img so alt-text covers optimised images — a common blind spot, since next/image will happily render without alt.
  • Remember that Server Components are linted identically; the plugin works on source, not on where the code runs.

How to Verify

  • Seed a violation. Add <div onClick={() => {}} /> and <IconButton icon={<Close />} /> to a scratch file. Both must be reported. If only the first is, the component map is not being applied.
  • Check the editor. Open the same file in the editor and confirm the squiggles appear there too, not just on the command line.
  • Count the rules. Run npx eslint --print-config src/app/page.tsx and grep for jsx-a11y. The list should match what you think you enabled.
  • Run with --max-warnings=0 in CI, so warnings cannot accumulate — see enforcing accessibility lint rules in pre-commit hooks.
  • Cross-check with a DOM scan. Run jest-axe on the same components; findings it reports that lint missed show where static analysis ends.

Common Accessibility Mistakes

  • Assuming the framework preset is enough. It enables a subset, and the omissions include the interactive-element rules that catch the worst bugs.
  • Skipping the component map. The plugin runs, reports nothing, and everyone concludes the codebase is clean.
  • Disabling no-static-element-interactions project-wide. It is noisy exactly where the bugs are; fix the components instead.
  • Blanket file-level disables. They hide every future violation in the file, not just today's.
  • Treating a clean run as an accessibility sign-off. Linting cannot see contrast, focus order, live regions or whether a name is accurate.
  • Mapping components inaccurately. Mapping a Card to button because it happens to be clickable produces confident nonsense; fix the component to render a real button instead.

Conclusion

Configuring jsx-a11y properly takes about twenty minutes and turns a decorative dependency into a working gate. Extend the plugin's own config rather than inheriting a framework subset, map every design-system component to the element it renders, error on the six high-value rules, and tune the two or three that would otherwise generate noise. Then verify by seeding a violation — because the failure mode here is not a false alarm, it is silence.

Frequently Asked Questions

Does jsx-a11y work with TypeScript? Yes, via @typescript-eslint/parser; the rules operate on JSX syntax, not types. Types complement it well: use the plugin for raw elements and discriminated union props for your own components, as covered in catching ARIA mistakes with TypeScript and ESLint.

Should I use recommended or strict?recommended for an existing codebase, strict for a new one. strict adds rules with more opinionated defaults, which is manageable when the count starts at zero and painful when it starts at four hundred.

Why does control-has-associated-label miss my button? Usually because the label is nested deeper than the rule's depth setting, or the component is not in the map. Raise depth to 3 and confirm the mapping; if the name comes from a runtime prop the rule genuinely cannot see it, and a type union is the better guard.

Can the plugin check dynamic role or aria values? Only when they are literals. role={someVariable} is invisible to static analysis, which is one reason to avoid computing roles at runtime — a component whose role varies is usually two components.

How do I keep the component map from going stale? Add a test that walks the design system's export list and asserts that every component rendering an interactive or media element appears in the map. It is twenty lines, it fails the moment someone adds a new Toggle without mapping it, and it removes the only maintenance burden this configuration has.

Should Storybook stories be linted too? Yes, and it is a cheap win: stories render the same components with a wide range of props, so violations in edge-case states surface there first. Keep the same rule set — a story that would fail in production should fail in the story file, not be exempted because it is "only a demo".

Does it slow down linting noticeably? No. The rules are AST-level and cheap; the cost in a typical repo is a few percent. If linting is slow, the cause is almost always type-aware TypeScript rules rather than jsx-a11y.