testing and automating accessibility

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 Content
  • 2.1.1 Keyboard
  • 4.1.2 Name, Role, Value
Staged-file linting versus whole-repo linting at commit time Two commit flows. Linting the whole repository takes around forty seconds and reports violations in files the developer never touched, so the commit is blocked for unrelated reasons and the hook gets bypassed. Linting only the staged files takes around two seconds, reports only what this change introduced, and blocks the commit for a reason the developer can act on immediately. Whole repository ~40 s on every commit Reports 412 pre-existing problems Blocks work the developer did not do Bypassed with --no-verify by Friday ✗ correct and useless Staged files only ~2 s on a typical commit Reports only what this change adds Actionable in the file already open Debt shrinks without blocking anyone ✓ kept, not bypassed A hook is a social contract as much as a technical one: it survives only while it is fast and relevant.

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 prepare script 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 --cache so 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.

How a baseline ratchet treats new versus existing violations A decision flow at commit time. The hook lints the staged files and compares the result with the stored baseline. If the change introduces a violation in a line it touched, the commit is blocked. If the violation already existed and the change did not touch that line, the commit is allowed and the baseline is unchanged. If the change fixes violations, the baseline is lowered automatically, so the count can never rise again. Lint the staged files New violation on a line this change actually touched commit blocked Pre-existing already in the baseline, untouched by this change commit allowed Violation fixed count drops below the stored baseline baseline lowered The baseline only ever moves down, so the debt shrinks without any commit being blocked for old code.

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. --fix is fine for formatting; an auto-inserted alt="" would hide a real decision.
  • Keep the output short. Three violations with context beat forty lines of summary.
Two failure messages for the same blocked commit A comparison of hook output. The unhelpful version prints only that lint-staged failed with a non-zero exit code, giving the developer nothing to act on. The helpful version names the file and line, states the rule, explains the fix in one sentence, links to the rule documentation, and notes that no-verify exists for emergencies with CI as the real gate. Unhelpful lint-staged failed exit code 1 The developer now has to re-run the command by hand to find out what happened. Helpful Card.tsx:42 — jsx-a11y/ no-static-element-interactions Use a real button for the click handler on this div. Docs link included; --no-verify documented, CI is the real gate. The message is the only teaching moment the hook gets — spend it on the fix, not on the exit code.

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 commit on 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-verify becoming muscle memory.
  • Leaving rules at warn without --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-verify and 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.