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 Content2.1.1 Keyboard4.1.2 Name, Role, Value3.1.1 Language of Page
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>withouthref, 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(orstrict) 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-validandlabel-has-associated-controlfirst — 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-autofocusandalt-textas the baseline. - For Svelte,
eslint-plugin-svelteincludes 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-templateprovidesaccessibility-*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.
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 ofstring, 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
erroras soon as its count reaches zero, so fixed categories cannot regress while the rest are still in progress. - Use targeted
eslint-disable-next-linecomments 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.
{
"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.jsonrecommending 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-a11yrules 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-syntaxfor 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
titleIdso 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, thecomponentssetting is not reaching your design system. - Run the full lint in CI with
--max-warnings=0and 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.
grepforeslint-disable.*a11yperiodically. 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(orvuejs-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=0everywhere.
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.
Related guides
- Testing & Automating Accessibility — the parent guide and the full testing pyramid.
- Component Testing with jest-axe — the rendered-DOM layer above linting.
- Gating Accessibility in CI/CD Pipelines — turning findings into a merge decision.
- Configuring eslint-plugin-jsx-a11y for React — the rule-by-rule configuration.
- Enforcing Accessibility Lint Rules in Pre-Commit Hooks — husky, lint-staged and staged-file scoping.
- Catching ARIA Mistakes with TypeScript and ESLint — types that make bad markup unrepresentable.
- Semantic HTML vs ARIA in Component Trees — the rules these lints encode.