HookStack
Back to catalogue
ValidationPostToolUse· Write|EditPostToolUseAfter tool execution · non-blocking· non-blocking

JSX accessibility guard

Catch WCAG failures the moment you write the JSX

Static accessibility lint on JSX (src/**/*.tsx) — no ESLint, no AST, instant: <Image> without alt, positive tabIndex, target="_blank" without rel="noopener", and onClick on non-interactive elements without a role or keyboard handler. These are the WCAG failures that lock keyboard and screen-reader users out of your UI — caught in the write loop, not in a costly audit. Non-blocking, tuned for low false-positives.

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. Catch WCAG failures the moment you write the JSX.

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 a missing alt or keyboard trap before commit
  • Keep the UI usable for keyboard and screen-reader users

Tags

#accessibility#a11y#wcag#jsx#react

settings.json fragment

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

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

#!/usr/bin/env node
// Garde d'accessibilité statique sur les composants (PostToolUse Write|Edit).
// Cible : src/**/*.tsx. Quatre règles à faible faux-positif, sans ESLint ni AST :
//   - <Image> (next/image) sans `alt`            → image non décrite
//   - tabIndex positif                            → casse l'ordre de tabulation
//   - <a target="_blank"> sans rel noopener       → sécurité + bonne pratique
//   - onClick sur div/span/li sans role ni clavier → contrôle inaccessible au clavier
// Non bloquant : cumule les violations dans un seul message.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';

// 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;
  while ((m = re.exec(content))) {
    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;
      }
    }
  }
  return tags;
}

const 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,
];

export function run(input, { readFile = readFileSync } = {}) {
  const filePath = input.tool_input?.file_path ?? '';
  if (!/\/src\/.*\.tsx$/.test(filePath)) return null;

  let content;
  try {
    content = readFile(filePath, 'utf8');
  } catch {
    return null;
  }

  const violations = 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',
  };
}

/* 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