Enforcing Accessibility Lint Rules in Pre-Commit Hooks
A pre-commit hook is the last cheap moment to catch an accessibility bug. After it, the cost curve climbs steeply: a CI failure costs a context switch, a review comment costs two people's attention, and a production bug costs a ticket and a release. The catch is that hooks are also the easiest thing in a repo to make hated — a slow hook gets bypassed with --no-verify within a week, and a hook that fails on unrelated legacy code gets bypassed on day one.
This guide sets up a hook that is fast enough to keep and strict enough to matter, using the rule configuration from configuring eslint-plugin-jsx-a11y for React.
WCAG Success Criteria Addressed (by prevention):
1.1.1 Non-text Content2.1.1 Keyboard4.1.2 Name, Role, Value
Prerequisites
- A working ESLint setup with accessibility rules configured and passing on new code.
- Node 18+ and a package manager that runs lifecycle scripts (
prepare). - Agreement on what the hook blocks. Deciding that in advance avoids the argument happening at commit time.
Install husky and lint-staged
The pairing is conventional for a reason: husky installs the Git hook, lint-staged works out which files are staged and passes them to the right command.
Implementation Guidelines:
- Install both as dev dependencies and add a
preparescript so hooks install on clone. - Keep the hook itself a one-liner that delegates to lint-staged; logic in the shell script is hard to test and easy to break.
- Commit the hook directory so every contributor gets the same behaviour.
- Do not add the hook to a repository where most contributors cannot run Node — a hook that half the team skips creates a false sense of coverage.
{
"scripts": {
"prepare": "husky",
"lint": "eslint . --max-warnings=0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --max-warnings=0 --cache --fix",
"*.vue": "eslint --max-warnings=0 --cache --fix"
}
}
# .husky/pre-commit
npx lint-staged
The --max-warnings=0 flag is what makes this an accessibility gate rather than a suggestion. Without it, rules configured as warnings pass the hook and accumulate forever.
Scope to Staged Files, Not the Repo
Speed is the difference between a hook people keep and one they bypass. lint-staged passes only the staged paths, which keeps the run proportional to the change.
Implementation Guidelines:
- Let lint-staged supply the file list; never hard-code
eslint .in the hook. - Enable
--cacheso unchanged files are skipped on repeat runs. - Keep the hook under about five seconds for a typical commit. Measure it, do not assume it.
- Put type-checking in CI rather than the hook — it is the slow step, and it rarely catches accessibility problems that lint does not.
Handle the Legacy Backlog With a Ratchet
If the repo has hundreds of existing violations, blocking every commit that touches an old file is a tax on unrelated work. A baseline turns that into a one-way ratchet instead.
Implementation Guidelines:
- Store a per-rule baseline count in a committed JSON file, and fail only when a count rises.
- Lower the baseline automatically whenever a run comes in under it, so improvements lock in without a manual step.
- Review the file in pull requests. An increase should be visible and deliberate, not buried in a diff.
- Delete a rule's baseline entry when it reaches zero and promote the rule to
error— the scaffolding has done its job.
// scripts/compare-a11y-baseline.mjs
import { readFileSync, writeFileSync } from 'node:fs';
const results = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const baseline = JSON.parse(readFileSync(process.argv[3], 'utf8'));
const counts = {};
for (const file of results) {
for (const message of file.messages) {
if (!message.ruleId?.startsWith('jsx-a11y/')) continue;
counts[message.ruleId] = (counts[message.ruleId] ?? 0) + 1;
}
}
let failed = false;
for (const [rule, count] of Object.entries(counts)) {
const allowed = baseline[rule] ?? 0;
if (count > allowed) {
console.error(`${rule}: ${count} violations, baseline ${allowed} — new problems introduced`);
failed = true;
}
}
if (!failed) writeFileSync(process.argv[3], JSON.stringify(counts, null, 2)); // ratchet down
process.exit(failed ? 1 : 0);
Make the Failure Message Teach Something
A hook that prints a wall of ESLint output and exits 1 gets read as an obstacle. A hook that says what to do gets read as help.
Implementation Guidelines:
- Print the rule's documentation URL — ESLint's stylish formatter already does, so do not suppress it.
- Add a short note about the escape hatch (
--no-verify) and when it is legitimate, so nobody has to discover it in frustration. - Do not auto-fix accessibility rules silently.
--fixis fine for formatting; an auto-insertedalt=""would hide a real decision. - Keep the output short. Three violations with context beat forty lines of summary.
How to Verify
- Seed a violation. Stage a file containing
<div onClick={() => {}} />and commit. The hook must block it, and the message must name the rule. - Time it. Run
time git commiton a one-file change; anything over five seconds needs the cache or a narrower glob. - Touch a legacy file. Add a comment to a file with existing violations and commit. It should succeed — otherwise the ratchet is not working and the hook will be bypassed.
- Fix something. Remove a violation and commit; the baseline file should shrink in the same commit.
- Check CI parity. Run the same command in the pipeline so a bypassed hook is still caught — see gating accessibility in CI/CD pipelines.
Common Accessibility Mistakes
- Linting the whole repo in the hook. Slow and irrelevant to the change; the fastest route to
--no-verifybecoming muscle memory. - Leaving rules at
warnwithout--max-warnings=0. The hook passes, the warnings pile up, and nobody notices for a year. - Auto-fixing accessibility rules. An inserted
alt=""silences the linter and loses the information; only formatting should be auto-fixed. - No escape hatch. Emergencies happen. Document
--no-verifyand let CI be the real gate. - Relying on the hook as the only gate. Hooks are local and skippable; the pipeline is what actually enforces the rule.
- Forgetting
prepare. Without it, hooks are not installed on a fresh clone and half the team is silently exempt.
Conclusion
A pre-commit accessibility gate is worth having only if developers keep it, so optimise for that: lint staged files only, cache aggressively, keep the run under a few seconds, and use a baseline ratchet so legacy code never blocks unrelated work. Print messages that teach, allow a documented escape hatch, and run the identical command in CI so nothing depends on the hook being honoured. Done this way the hook catches the mistakes worth catching at the moment they are cheapest to fix — and nobody has to be persuaded to leave it enabled.
Finally, review the hook itself occasionally. Repositories accumulate checks, and a pre-commit hook that started at two seconds is often at fifteen a year later because four unrelated tools were added to it. Measure it every few months, move anything slow into CI, and keep the local hook focused on the checks that genuinely benefit from immediate feedback. A fast hook stays enabled, and a hook that is enabled is the only kind that catches anything.
Frequently Asked Questions
Should the hook run tests as well as lint? Generally no. Tests belong in CI, where they can run in parallel and take as long as they need. A pre-commit hook should do only what is fast and local. The exception is a tiny, targeted subset — a few jest-axe component tests for files that changed — if it stays within the time budget.
What if someone commits with --no-verify?
CI catches it. That is the point of running the same command in the pipeline: the hook is a convenience that saves a round trip, not the enforcement mechanism. Treat frequent bypassing as a signal that the hook is too slow or too noisy rather than a discipline problem.
Is a pre-push hook better than pre-commit? Pre-push runs less often and interrupts less, which suits slower checks; pre-commit gives faster feedback on the file you are still looking at. Many teams use both: lint on commit, type-check and test on push.
How do I handle generated files? Exclude them in the ESLint config rather than in the lint-staged glob, so the exclusion applies everywhere consistently. Generated markup is still shipped to users, though — if it has accessibility problems, fix the generator.
How should the hook behave during a rebase or merge?
Skip it. Git sets GIT_REFLOG_ACTION during rebases and merges, and lint-staged handles partially-staged states poorly mid-conflict. Detect those cases and exit early — a hook that fires halfway through a conflict resolution produces confusing failures on code the developer has not finished writing.
Does the baseline approach work for jest-axe violations too? Yes, with the same shape: count violations per rule, store the counts, fail on an increase. It is more valuable for lint because lint counts are deterministic; DOM scan counts can vary with test data, so keep the baseline coarse if you apply it there.
Related guides
- Accessibility Linting in Editors & Builds — the parent guide covering the whole feedback loop.
- Configuring eslint-plugin-jsx-a11y for React — the rules this hook enforces.
- Gating Accessibility in CI/CD Pipelines — the server-side gate the hook mirrors.
- Failing Pull Requests on axe Violations — reporting findings where reviewers see them.