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 Content2.1.1 Keyboard3.3.2 Labels or Instructions4.1.2 Name, Role, Value
Prerequisites
- ESLint 9+ with flat config (
eslint.config.js); the legacy.eslintrcequivalents are noted where they differ. - A React codebase, optionally with Next.js and
eslint-config-nextalready 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-a11yas a direct dev dependency, even if a framework config already pulls it in transitively. - Extend
jsxA11y.flatConfigs.recommended(orstrictif 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
Fieldthat renders<input>should be mapped, orlabel-has-associated-controlwill not see it. - Where a component renders different elements depending on props (
asorhref), 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.
Implementation Guidelines:
- Set the six rules above to
erroronce their counts are at zero; they have very low false-positive rates. - Configure
label-has-associated-controlwithassert: 'either'so both wrapping andhtmlForstyles pass. - Tune
anchor-is-validto the aspects you care about — with Next.js<Link>,['invalidHref', 'preferButton']avoids noise while keeping the real check. - Leave
no-autofocuson. 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 asoptionon<li>inside a listbox.no-onchange: obsolete for React and safe to disable — React'sonChangeis the input event, not the HTMLchangeevent.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.
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-nextfor the framework rules, and layer the accessibility config after it. - Map
Linktoasoanchor-has-contentandanchor-is-validapply to routed links. - Map
Imagetoimgsoalt-textcovers optimised images — a common blind spot, sincenext/imagewill happily render withoutalt. - 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.tsxand grep forjsx-a11y. The list should match what you think you enabled. - Run with
--max-warnings=0in 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-interactionsproject-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
Cardtobuttonbecause 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.
Related guides
- Accessibility Linting in Editors & Builds — the parent guide and the wider feedback loop.
- Enforcing Accessibility Lint Rules in Pre-Commit Hooks — running these rules before code lands.
- Catching ARIA Mistakes with TypeScript and ESLint — the type-level half of the same job.
- Component Testing with jest-axe — the rendered-DOM layer that catches what lint cannot.