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 recommended and strict configurations.
  • 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.
Where linting sits in the testing stack Layers: linting catches static JSX mistakes in the editor; unit tests check component behaviour; axe scans rendered states in CI; screen reader smoke tests and manual audits catch the rest. Where linting sits in the testing stackLinting (editor,pre-commit)static JSX mistakesComponent testsroles, names, announcementsaxe scans (CI)rendered statesScreen reader smoketestsreal speechManual auditsjudgement and usability
Linting is the cheapest, earliest layer.

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.

Lint-only confidence versus layered testing Comparison: relying only on linting misses runtime and behavioural issues; linting as the first of several layers catches cheap issues early and leaves the rest to other tests. Lint-only confidence versus layered testing✗ Lint passes = accessibleGlobal disables for noisy rulesCustom components uncheckedRuns only in CI✓ Lint catches static issuesTargeted disables with reasonsComponents mapped to elementsRuns in editor and pre-commit

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
Issues caught by layer in one audit Bar chart of issues found in a data product audit by the cheapest layer that would have caught them: linting 34, component tests 21, axe scans 27, screen reader and manual testing 18. Issues caught by layer in one auditLintingissuesComponent testsissuesaxe scansissuesScreen reader + manualissues
Linting catches a third of issues earliest.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Install. Add eslint-plugin-jsx-a11y and extend strict (or recommended for legacy code, tightening later).
  2. Map components. Tell the plugin which design system components render which native elements, so rules apply to them.
  3. Tune for grids. Allow tabindex on grid roles, and keep keyboard rules at error level everywhere else.
  4. Disable narrowly. Use line-level disables with a comment explaining why, never global disables for noisy rules.
  5. 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.

Permalink to "Related"

← Back to Automated Accessibility Testing Pipelines