testing and automating accessibility

Accessibility Linting in Editors & Builds

The cheapest accessibility bug is the one that never compiles. A lint rule that flags <div onClick> while the developer is still looking at the line costs seconds to fix; the same bug found by an audit three months later costs a ticket, a context switch, a regression test and a release. Linting sits at the base of the testing pyramid for exactly that reason — it is the fastest feedback available, and the only layer that runs before the code exists in a browser.

This guide covers what static analysis can and cannot see, how to configure it for React, Next.js and Vue, how to move rules from warnings to errors without stalling the team, and where to stop. It sits under Testing & Automating Accessibility and hands off to Component Testing with jest-axe, which checks the rendered DOM that linting can only guess at.

WCAG Success Criteria Addressed (indirectly, by prevention):

  • 1.1.1 Non-text Content
  • 2.1.1 Keyboard
  • 4.1.2 Name, Role, Value
  • 3.1.1 Language of Page
Where accessibility linting sits in the feedback loop A left-to-right pipeline with four gates and increasing feedback delay. The editor gives feedback in about one second and catches static JSX mistakes. The pre-commit hook takes a few seconds and blocks the same class of problem before it enters history. Continuous integration takes minutes and runs both lint and rendered-DOM tests. A manual audit takes days and is the only stage that judges whether names and reading order actually make sense. A band underneath notes that the further right a bug is found, the more it costs to fix. Editor ~1 second missing alt, div with a click handler Pre-commit seconds same rules, on staged files only CI minutes lint + jest-axe + Playwright scans Manual audit days is the name right? does the order read? Cost of a fix rises with every stage to the right Linting is the only layer that runs before the code has ever rendered Static analysis reads source, not output: it can prove an attribute is missing, never that a name is meaningful.

What Static Analysis Can and Cannot See

A linter reads source text. It sees the JSX you wrote, not the DOM the browser built, which draws a hard line through the set of accessibility problems it can detect.

Implementation Guidelines:

  • Expect linting to catch structural absences: a missing alt, an <a> without href, a click handler on a non-interactive element, an invalid ARIA attribute or value.
  • Do not expect it to catch anything computed: contrast ratios, focus order, whether a live region actually fires, or whether an accessible name is accurate.
  • Treat rule coverage as a floor, not a score. A clean lint run means "no obvious mistakes in the source", which is a much weaker statement than "accessible".
  • Fill the gap deliberately: rendered-DOM assertions in jest-axe, real-browser behaviour in Playwright, and human judgement in screen reader testing.

The single most useful thing a linter does is not the individual rule — it is turning an invisible category of mistake into a red squiggle at the moment of writing. Developers who see jsx-a11y/no-static-element-interactions fire twice tend to reach for <button> the third time without prompting.

Configuring jsx-a11y for React and Next.js

eslint-plugin-jsx-a11y is the de facto standard for React. Next.js ships a subset of its rules through eslint-config-next, which is a reasonable starting point and a poor stopping point — the built-in set is deliberately conservative.

Implementation Guidelines:

  • Install the plugin explicitly and extend its recommended (or strict) config rather than relying on the framework's defaults.
  • Map custom components to the elements they render, so the plugin can see through your abstraction layer.
  • Turn on no-static-element-interactions, click-events-have-key-events, anchor-is-valid and label-has-associated-control first — they account for the majority of real findings.
  • Keep the config in the repo, not in individual editors, so everyone gets identical feedback.
// eslint.config.js — flat config
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  jsxA11y.flatConfigs.recommended,
  {
    settings: {
      'jsx-a11y': {
        // Teach the plugin what our design-system components render, so it can
        // check <Button> and <TextField> the way it checks <button> and <input>.
        components: {
          Button: 'button',
          IconButton: 'button',
          TextField: 'input',
          Link: 'a',
          Image: 'img',
        },
      },
    },
    rules: {
      'jsx-a11y/no-static-element-interactions': 'error',
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
      'jsx-a11y/anchor-is-valid': ['error', { aspects: ['invalidHref', 'preferButton'] }],
      // Next.js routes with <Link> render an <a>, so both are valid anchors.
      'jsx-a11y/anchor-has-content': 'error',
    },
  },
];

The components mapping is what turns the plugin from decorative into useful in a design-system codebase. Without it, every accessibility-relevant element is hidden behind a capitalised name the linter ignores, and the rule set quietly checks nothing at all.

Vue, Nuxt and Other Frameworks

The same idea exists outside React, with different plugin names and a template-based parser.

Implementation Guidelines:

  • For Vue and Nuxt, use eslint-plugin-vuejs-accessibility, which parses SFC templates and offers rules mirroring most of jsx-a11y.
  • Enable vuejs-accessibility/form-control-has-label, click-events-have-key-events, no-autofocus and alt-text as the baseline.
  • For Svelte, eslint-plugin-svelte includes a11y rules, and the Svelte compiler itself emits accessibility warnings — treat those compiler warnings as errors in CI rather than scrolling past them.
  • For Angular, the template compiler plus @angular-eslint/eslint-plugin-template provides accessibility-* rules covering labels, roles, alt text and interactivity.
// Vue / Nuxt flat config
import vueA11y from 'eslint-plugin-vuejs-accessibility';

export default [
  ...vueA11y.configs['flat/recommended'],
  {
    rules: {
      'vuejs-accessibility/no-autofocus': 'error',
      'vuejs-accessibility/form-control-has-label': 'error',
      'vuejs-accessibility/anchor-has-content': 'error',
    },
  },
];

Types as a Second Linter

TypeScript catches a class of accessibility bug that ESLint structurally cannot: a component API that permits an unnamed control. Where a lint rule reacts to a mistake, a type prevents it from being expressible.

Two ways to stop an unnamed icon button Two paths from the same mistake. On the left, a permissive prop type accepts an icon button with no label; the mistake survives compilation and is caught later, if at all, by a lint rule or a rendered-DOM test. On the right, a union type requires either visible children or an aria-label, so the same code fails to compile and the developer fixes it in the editor before the component ever renders. Permissive props Union type label?: string every call site compiles { children } | { 'aria-label': string } the nameless case is unrepresentable Ships an unnamed button found later by a test, an audit, or a user Fails to compile fixed in the editor, seconds after writing Lint rules react to mistakes. Types remove the ability to make them. Use both: types for your own components, lint rules for raw elements.

Implementation Guidelines:

  • Model "named or decorative" as a union type wherever a component renders an image, an icon button, or any control without visible text.
  • Make id-based associations explicit in props (labelId, describedById) rather than generating them silently, so a caller cannot forget the relationship exists.
  • Type ARIA attributes with the framework's own definitions (React.AriaAttributes) instead of string, so invalid values are rejected.
  • Do not overreach: types cannot express "this label is accurate", and a type gymnastics exercise that nobody understands will be any-cast around within a month.
type WithVisibleText = { children: React.ReactNode; 'aria-label'?: never };
type WithAriaLabel = { children?: never; 'aria-label': string };

// A11y rationale: 4.1.2 Name, Role, Value — a button with neither visible text
// nor an aria-label is not expressible, so it cannot reach review.
export type ButtonProps = (WithVisibleText | WithAriaLabel) &
  React.ButtonHTMLAttributes<HTMLButtonElement>;

Rolling Rules Out Without Stalling the Team

Turning on strict in a mature codebase produces four hundred errors and a pull request nobody can review. The rollout matters as much as the config.

Implementation Guidelines:

  • Start with the rules at warn, measure the count, and fix in themed batches — all missing alts, then all interactive divs.
  • Promote each rule to error as soon as its count reaches zero, so fixed categories cannot regress while the rest are still in progress.
  • Use targeted eslint-disable-next-line comments with a short reason rather than blanket file-level disables; the comment is where the next reader learns why.
  • Gate only new and changed files at first if the backlog is large — lint-staged does this naturally, and it stops the debt growing while you pay it down.
Rolling rules out with a ratchet instead of a big-bang fix A descending step chart of remaining violations over four sprints, starting at four hundred and twelve and falling to zero. Each step is labelled with the themed batch fixed in that sprint: missing alt text, interactive divs, unlabelled form controls, then invalid ARIA. A dashed ceiling line above the steps marks the baseline the build fails against, dropping with each step. A note explains that the rule is promoted from warning to error the moment its count reaches zero. 412 0 Sprint 1 alt text Sprint 2 interactive divs Sprint 3 form labels Sprint 4 invalid ARIA Dashed line: the baseline CI fails against. It only ever moves down, and each rule becomes an error at zero.
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --max-warnings=0 --fix"],
    "*.{vue}": ["eslint --max-warnings=0 --fix"]
  }
}

--max-warnings=0 is the important flag: without it, warnings accumulate silently and the "we'll promote them to errors later" plan quietly never happens. Wiring the same command into the pipeline is covered in enforcing accessibility lint rules in pre-commit hooks and, for the server side, gating accessibility in CI/CD pipelines.

Editor Feedback Beats Every Other Channel

A rule that only fires in CI teaches nothing — by then the developer has moved on. The same rule in the editor changes how the next component gets written.

Implementation Guidelines:

  • Commit a .vscode/extensions.json recommending the ESLint extension, and an editor settings file enabling lint-on-save.
  • Keep the lint run fast enough to stay in the editor's budget; a config that takes eight seconds per file gets disabled by the first person who notices.
  • Add rule documentation links to custom messages. jsx-a11y rules link to their docs by default, and that page is usually a better explanation than a code review comment.
  • Pair the linter with an in-browser checker (axe DevTools, the browser's own accessibility panel) so developers can see the rendered result of what the linter guessed at.

Custom Rules for Your Own Patterns

Off-the-shelf rules cover the language, not your codebase. Every design system accumulates conventions — a Dialog that must receive titleId, a Field that must wrap a labelled control, an internal useAnnounce hook that must not be called during render — and those conventions are exactly what new contributors get wrong.

Implementation Guidelines:

  • Write a custom rule when a convention has been explained twice in code review. The second explanation is the signal.
  • Keep custom rules narrow and mechanical: "this component requires this prop" is a good rule; "this dialog should be usable" is not a rule at all.
  • Use no-restricted-syntax for one-off checks before investing in a real plugin — an AST selector plus a message solves a surprising number of cases with no build tooling.
  • Give every custom rule a message that says what to do, not what is wrong: "Pass titleId so the dialog is named by its heading" beats "invalid Dialog usage".
// A one-line guard, no plugin required: our Dialog must always be named.
{
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'JSXElement[openingElement.name.name="Dialog"]:not(:has(JSXAttribute[name.name="titleId"]))',
      message: 'Dialog needs titleId so the dialog is labelled by its heading (4.1.2).',
    }],
  },
}

The same approach catches API misuse that would otherwise only show up in a screen reader: a LiveRegion used without a politeness prop, an IconButton rendered with a title instead of a label, a raw <input> outside the Field wrapper that owns label association. None of these are general accessibility rules; all of them are accessibility bugs in your specific code.

Reporting, Baselines and Keeping Score

A linting programme that reports "0 errors" from day one is usually mis-configured, and one that reports "412 warnings" forever teaches the team to ignore it. What keeps a rollout honest is a number that visibly moves.

Implementation Guidelines:

  • Record the per-rule violation count as a build artefact — ESLint's JSON formatter plus a five-line script is enough — and chart it over time.
  • Set the baseline at today's count and fail the build when the count increases. That converts an unbounded backlog into a ratchet without blocking unrelated work.
  • Break the number down by rule, not by file. Rule-level counts tell you which convention is failing to land; file-level counts tell you nothing actionable.
  • Delete the baseline once a rule hits zero and promote it to error. A ratchet is scaffolding, not architecture.
# Emit a machine-readable report CI can compare against the stored baseline.
npx eslint . --format json --output-file reports/eslint.json
node scripts/compare-a11y-baseline.mjs reports/eslint.json .a11y-baseline.json

Publishing that trend alongside the axe violation count from the rendered-DOM tests gives a fuller picture than either number alone: linting measures how much bad markup gets written, while axe measures how much survives to the browser. When the first falls and the second does not, the gap is almost always in dynamic behaviour — state, focus and announcements — which no static tool can see.

How to Verify

  • Break it deliberately. Add <div onClick={() => {}} /> and <img src="x.png" /> to a scratch file and confirm both are flagged, in the editor and on the command line. A rule that is configured but silent is worse than no rule.
  • Check the component mapping. Write <Button /> with no children and no label; if nothing is reported, the components setting is not reaching your design system.
  • Run the full lint in CI with --max-warnings=0 and confirm a seeded violation fails the job — see failing pull requests on axe violations for the reporting side.
  • Compare against a rendered scan. Run jest-axe over the same components; anything the DOM scan finds that the linter missed is a reminder of where the line falls.
  • Review the disable comments. grep for eslint-disable.*a11y periodically. A growing list is a design problem, not a linting problem.
  • Time the run. Measure lint duration on a cold cache and on a single changed file. If the single-file case exceeds a second or two, editor feedback will feel laggy and someone will turn it off — cache aggressively (--cache) and scope the pre-commit run to staged files.
  • Confirm the editor and CI agree. Run the exact CI command locally and compare the counts. A mismatch usually means the editor is loading a different config file, which produces the worst outcome of all: green locally, red in the pipeline, and no trust in either.

Key Takeaways

  • Linting is the fastest accessibility feedback available, and the only kind that runs before the code renders.
  • It detects absences, not meanings: missing attributes yes, accurate names and focus order no.
  • Extend jsx-a11y (or vuejs-accessibility) explicitly and map your custom components, or the rules check nothing.
  • Types prevent a whole class of naming bug by making the unnamed case unrepresentable.
  • Roll rules out as warnings, fix in themed batches, then promote to errors so fixed categories stay fixed.
  • Run the same rules in the editor, in a pre-commit hook and in CI, and keep --max-warnings=0 everywhere.

Frequently Asked Questions

Is eslint-config-next enough on its own? It is a reasonable default and an incomplete one. Next.js enables a subset of jsx-a11y rules and leaves several high-value ones off, including the interactive-element rules that catch <div onClick>. Extend the plugin's own recommended config on top and you keep the framework's defaults while closing the gaps.

What percentage of accessibility issues does linting catch? Far less than people assume — single digits of the total issue count in most audits, because the issues it can see are also the easiest to avoid. Its value is not coverage but latency: it removes an entire class of mistake from the codebase permanently, at zero marginal cost per run.

Should lint failures block a merge? Yes, once the backlog is at zero for the rules you have enabled. Before that, block only on new and changed files. A gate that fails on unrelated legacy code trains everyone to bypass it, which costs more than the rule was ever worth.

How do I handle a legitimate rule violation? Disable it on the specific line with a comment explaining why, and prefer a narrower fix if one exists. If the same disable appears in five places, the component API is fighting the rule — change the API. Blanket disables at the top of a file hide future violations you would want to know about.

Can a linter replace an accessibility review? No, and framing it that way is how programmes fail. Linting removes the mistakes that are mechanical to spot, which frees review time for the judgement calls — is this the right control, does the reading order make sense, would a screen reader user understand this flow. A clean lint run is the price of admission to that conversation, not a substitute for it.

How do I lint accessibility in templates that are generated at runtime? You cannot, and that is the honest boundary. Markup assembled from CMS content, feature flags or user input never passes through the linter, so it needs a runtime check instead: a rendered-DOM scan in tests for the component that renders it, and a spot-check of real production content. Treat every dynamic branch as unlinted by definition and cover it one layer up.

Do linters understand ARIA? Partially. They validate that an attribute exists, that its value is one of the permitted tokens, and that roles are not obviously misapplied — for example aria-required-attr and role-supports-aria-prop. They cannot know whether aria-expanded matches what is actually on screen, which is what a rendered-DOM test is for.