HookStack
Back to catalogue
ValidationPostToolUse· Write|Edit

JSX accessibility guard

WCAG violations caught on every JSX save, no plugin needed

Progressive enhancement accessibility guard on src/**/*.tsx|jsx. When Biome is installed: runs its native a11y lint rules (no plugin needed) scoped with --only=a11y. When Biome is absent: falls back to 4 instant static checks (alt, tabIndex, target=_blank, onClick without keyboard). One hook, best coverage available, zero mandatory dependencies.

What does the JSX accessibility guard hook do?

JSX accessibility guard is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. WCAG violations caught on every JSX save, no plugin needed.

As a PostToolUse hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Catch missing alt, invalid aria-props, or keyboard traps the moment you write them
  • Get full ESLint WCAG coverage when jsx-a11y is installed, static checks otherwise
  • Keep the UI accessible for keyboard and screen-reader users without any mandatory setup

Tags

#accessibility#a11y#wcag#jsx#react

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/a11y-jsx-guard.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/a11y-jsx-guard.mjs

#!/usr/bin/env node
// @hookstack a11y-jsx-guard
import { execSync } from "node:child_process";
// @hookstack a11y-jsx-guard
// Garde d'accessibilité JSX sur les composants src/**/*.tsx|jsx (PostToolUse Write|Edit).
// Progressive enhancement :
//   • Biome installé → règles a11y natives (lint/a11y/*, aucun plugin requis)
//   • Biome absent   → vérifications statiques (4 règles regex, zéro dépendance)
// Non bloquant : cumule les violations dans un message.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

// ── Chemin Biome ───────────────────────────────────────────────────────────────

function defaultExec(cmd) {
	return execSync(cmd, { stdio: "pipe", timeout: 20_000, encoding: "utf8" });
}

function runBiome(filePath, { exec }) {
	try {
		exec(
			`npx --no-install biome lint --only=a11y --reporter=rdjson "${filePath}"`,
		);
		return null;
	} catch (err) {
		const raw = err.stdout?.toString() ?? "";
		let parsed;
		try {
			parsed = JSON.parse(raw);
		} catch {
			return null;
		}

		const msgs = (parsed.diagnostics ?? []).filter((d) =>
			d.code?.value?.startsWith("lint/a11y/"),
		);
		if (!msgs.length) return null;

		return {
			message:
				`[a11y] ${filePath.split("/").pop()} accessibility violations:\n` +
				msgs
					.map(
						(m) =>
							`  ✗ ${m.code.value}: ${m.message} (line ${m.location?.range?.start?.line ?? "?"})`,
					)
					.join("\n") +
				"\n",
		};
	}
}

// ── Chemin statique (fallback zéro-dépendance) ────────────────────────────────
//
// Extrait chaque balise ouvrante d'un type donné, du `<tag` jusqu'au `>` de fermeture
// au niveau 0. On suit la profondeur des accolades et on saute les chaînes : ainsi un
// `>` issu d'une fonction fléchée `() =>` dans une prop, ou d'un `>` dans une string,
// ne tronque pas la balise (sinon role=/onKeyDown placés après seraient ignorés).
function openingTags(content, tag) {
	const tags = [];
	const re = new RegExp(`<${tag}\\b`, "g");
	let m = re.exec(content);
	while (m) {
		let depth = 0;
		let quote = null;
		for (let j = m.index; j < content.length; j++) {
			const ch = content[j];
			if (quote) {
				if (ch === quote) quote = null;
			} else if (ch === '"' || ch === "'" || ch === "`") {
				quote = ch;
			} else if (ch === "{") {
				depth++;
			} else if (ch === "}") {
				depth--;
			} else if (ch === ">" && depth === 0) {
				tags.push(content.slice(m.index, j + 1));
				break;
			}
		}
		m = re.exec(content);
	}
	return tags;
}

const STATIC_CHECKS = [
	(c) =>
		openingTags(c, "Image").some((t) => !/\balt\s*=/.test(t))
			? '<Image> without an `alt` prop → describe the image (alt="" only if purely decorative)'
			: null,
	(c) =>
		/\btabIndex=\{?\s*['"]?[1-9]\d*/.test(c)
			? "positive tabIndex → breaks natural tab order; use tabIndex={0} or restructure the DOM"
			: null,
	(c) =>
		openingTags(c, "a").some(
			(t) =>
				/target=['"]_blank['"]/.test(t) && !/\brel=['"][^'"]*noopener/.test(t),
		)
			? 'target="_blank" without rel="noopener noreferrer" → security + SEO best practice'
			: null,
	(c) =>
		["div", "span", "li"].some((tag) =>
			openingTags(c, tag).some(
				(t) =>
					/\bonClick=/.test(t) &&
					!/\brole=/.test(t) &&
					!/\bonKey(?:Down|Press|Up)=/.test(t),
			),
		)
			? "onClick on a non-interactive element (div/span/li) without role + keyboard handler → use <button>, or add role and onKeyDown"
			: null,
];

function runStatic(filePath, { readFile }) {
	let content;
	try {
		content = readFile(filePath, "utf8");
	} catch {
		return null;
	}
	const violations = STATIC_CHECKS.map((check) => check(content)).filter(
		Boolean,
	);
	if (!violations.length) return null;
	return {
		message:
			`[a11y] ${filePath} has accessibility issues:\n` +
			violations.map((v) => `  - ${v}`).join("\n") +
			"\n",
	};
}

// ── Point d'entrée ─────────────────────────────────────────────────────────────

export function run(
	input,
	{
		exec = defaultExec,
		exists = existsSync,
		readFile = readFileSync,
		projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
	} = {},
) {
	const filePath = input.tool_input?.file_path ?? "";
	if (!/\/src\/.*\.[jt]sx$/.test(filePath)) return null;

	if (exists(join(projectDir, "node_modules/@biomejs/biome"))) {
		return runBiome(filePath, { exec });
	}

	return runStatic(filePath, { readFile });
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks