Accessibility Linting with eslint-plugin-jsx-a11y
Permalink to "Accessibility Linting with eslint-plugin-jsx-a11y"Linting is the earliest point to catch accessibility bugs: an onClick on a div without keyboard handling, an icon button without a name, an invalid ARIA attribute, a tabIndex greater than zero. For React and other JSX codebases, eslint-plugin-jsx-a11y checks JSX statically as developers type. It cannot see runtime behaviour, but it removes a whole class of mistakes from data components before they reach review.
This guide belongs to automated accessibility testing pipelines.
Spec reference
Permalink to "Spec reference"- eslint-plugin-jsx-a11y — static rules for JSX, with
recommendedandstrictconfigurations. - SC 2.1.1, 4.1.2, 1.1.1 (A) — most rules map to keyboard access, names and roles, and text alternatives.
- WAI-ARIA — rules validate roles, attributes and allowed values.
- Limits — no runtime state, computed names or focus behaviour.
When to use — and when not to
Permalink to "When to use — and when not to"Use linting in every JSX codebase. It costs minutes to set up and runs constantly.
Do not treat a clean lint run as accessibility sign-off. The misapplication: disabling click-events-have-key-events across the codebase because grid rows trigger it, instead of fixing the rows or disabling it narrowly.
Annotated code example
Permalink to "Annotated code example"// eslint.config.js (flat config)
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
jsxA11y.flatConfigs.strict,
{
settings: {
'jsx-a11y': {
components: { Button: 'button', IconButton: 'button', Link: 'a', TextField: 'input' }, // map DS components
attributes: { for: ['htmlFor', 'for'] },
},
},
rules: {
'jsx-a11y/no-noninteractive-tabindex': ['error', { roles: ['grid', 'gridcell', 'row'] }], // grids may use tabindex
'jsx-a11y/control-has-associated-label': ['error', { ignoreElements: ['td'] }],
},
},
];
// Caught: clickable row without keyboard support or role
<tr onClick={() => open(id)}>…</tr> // click-events-have-key-events, no-noninteractive-element-interactions
// Fix: a link in the row header
<tr><th scope="row"><a href={`/invoices/${id}`}>{id}</a></th>…</tr>
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Rule | Data UI mistake it catches |
|---|---|
| click-events-have-key-events | Clickable rows and cells without keys |
| no-static-element-interactions | onClick on div/span cells |
| control-has-associated-label | Unnamed icon buttons in rows |
| aria-props / aria-proptypes | Typos like aria-sorted, invalid values |
| role-has-required-aria-props | role=“slider” without aria-valuenow |
| tabindex-no-positive | tabIndex={1} in toolbars |
Step-by-step implementation
Permalink to "Step-by-step implementation"- Install. Add
eslint-plugin-jsx-a11yand extendstrict(orrecommendedfor legacy code, tightening later). - Map components. Tell the plugin which design system components render which native elements, so rules apply to them.
- Tune for grids. Allow
tabindexon grid roles, and keep keyboard rules at error level everywhere else. - Disable narrowly. Use line-level disables with a comment explaining why, never global disables for noisy rules.
- Run everywhere. Editor integration for instant feedback, a pre-commit hook for changed files and CI for the whole repo.
Integration context
Permalink to "Integration context"Runtime scans are covered in setting up axe-core in a GitHub Actions pipeline and writing jest-axe tests for data grid components. False positives in axe are discussed in configuring axe-core rules and false positives. Trends are tracked in tracking violation trends over time. Component library defaults are in React data grid libraries.
Gotchas
Permalink to "Gotchas"Polymorphic components. Components with an as prop render different elements; the plugin’s polymorphicPropName setting helps.
Spread props. Names passed through spread props ({...labelProps}) are invisible to the linter; expect some false positives.
Vue and Angular. Use eslint-plugin-vuejs-accessibility and @angular-eslint/template accessibility rules for equivalent checks.
Edge cases
Permalink to "Edge cases"- Generated JSX. Code generated from schemas may need lint at generation time.
- Legacy code. Introduce rules as warnings for existing files and errors for new ones using overrides.
- Third-party components. The linter cannot see inside them; rely on runtime scans.
Worked example
Permalink to "Worked example"A logistics company’s React codebase had clickable table rows throughout, each with an onClick and no keyboard support. Enabling strict jsx-a11y rules produced 140 errors; the team fixed them by adding links in row headers and converting icon-only divs to buttons, then turned the rules on as errors in CI. New clickable rows stopped appearing in code review because developers saw the error in their editors.
Common mistakes
Permalink to "Common mistakes"- Treating lint as complete testing.
- Global disables of noisy rules.
- Not mapping design system components.
- Running only in CI, not in editors.
Testing checklist
Permalink to "Testing checklist"Testing it with a screen reader
Permalink to "Testing it with a screen reader"Linting is static, so pair it with a quick check of what it flagged. When a rule fires on a component, open the component with NVDA and confirm the issue: tab to the clickable row and notice nothing happens, or reach an icon button and hear only “button”. After fixing, repeat to confirm the improvement. This builds trust in the rules and helps developers understand why they exist.
Rolling out to a large codebase
Permalink to "Rolling out to a large codebase"Turning on strict rules in a large codebase produces hundreds of errors at once, which teams tend to suppress wholesale. Roll out in steps instead: enable the rules as errors for new and changed files only, using lint-staged or an override for a “new code” directory; fix existing violations component by component, starting with shared components that appear everywhere; and publish a weekly count so progress is visible. Within a few months, the rules can become errors everywhere.
Rules most often disabled — and what to do instead
Permalink to "Rules most often disabled — and what to do instead"no-autofocus is often disabled for dialogs; instead, focus the first field from code when the dialog opens, which the rule allows. no-static-element-interactions is disabled for clickable cards and rows; instead, use a link or button inside them. label-has-associated-control complains about custom inputs; map them in settings or use htmlFor correctly. no-noninteractive-tabindex fires on scrollable table containers, which do need tabIndex={0}; add a line-level disable with a comment pointing to the reflow guidance. Each of these has a better fix than a global disable, and reviewing disables quarterly keeps them from accumulating.
What linting cannot see
Permalink to "What linting cannot see"Linting reads source, not the running page. It cannot tell whether a computed accessible name is meaningful, whether focus moves correctly after a row is deleted, whether a live region exists before its message, or whether colours contrast. Keep a short list of these gaps next to your lint config so developers know which tests cover them.
Key points
Permalink to "Key points"- Linting is the cheapest, earliest accessibility check; run it in the editor.
- Map design system components so rules see what they render.
- Disable rules narrowly with a reason, never globally.
- Pair linting with runtime and screen reader tests.
Design system notes
Permalink to "Design system notes"Publish a shared ESLint config with the design system that includes component mappings and agreed rule settings for data components.
FAQ
Permalink to "FAQ"Recommended or strict?
Strict for new code; recommended as a stepping stone for legacy code.
Does it work with TypeScript?
Yes, with the TypeScript ESLint parser.
Will it catch missing table headers?
Partly; runtime scans are better for table structure.
How do I handle false positives?
Line-level disables with comments, or adjust component mappings.
Is there an equivalent for Vue?
Yes, eslint-plugin-vuejs-accessibility.
Can it check ARIA patterns?
It validates roles and attributes, not keyboard behaviour.
Related
Permalink to "Related"- Writing jest-axe tests for data grid components — runtime checks
- Configuring axe-core rules and false positives — tuning rules
- Tracking violation trends over time — measuring progress