Automating Alt-Text Remediation with Codemods

Missing alt attributes are the single most common machine-detectable accessibility violation, and they scatter across a codebase in the hundreds. This page builds a jscodeshift/ts-morph codemod that finds every <img> and <Image> lacking an accessible name and inserts a reviewable placeholder — alt="" for genuinely decorative images, or a TODO-tracked attribute for meaningful ones that a human must caption. It is part of Codemod-Driven Accessibility Fixes, within the wider Automated Remediation & Accessibility Fixing Patterns section.

The criterion at stake is WCAG 2.2 SC 1.1.1 (Non-text Content): every image must have a text alternative whose value matches its purpose. The hard truth is that a codemod cannot know an image’s purpose — so the correct automated behavior is not to invent alt text, but to insert a placeholder that keeps CI red until a human fills in real text.

  • Target: <img> (and <Image> from common frameworks) with no alt and no aria-label.
  • Decorative images get alt=""; meaningful images get a flagged placeholder that fails the scan.
  • The codemod never writes prose — it stages the work, it does not complete it.
  • WCAG 2.2 SC 1.1.1 is the governing criterion; the matching scanner rule is image-alt.
Alt-text remediation decision flow An image element with no accessible name is classified: decorative images receive an empty alt, meaningful images receive a TODO placeholder that keeps the scan failing until a human writes real text. img / Image no accessible name Classify decorative? alt="" decorative, scan passes data-a11y-todo meaningful, scan fails yes no / unknown
When purpose is unknown, the codemod defaults to the failing branch — it never silently passes a meaningful image.

Root Cause / Context

A scanner flags a missing alt because the image-alt rule maps directly to WCAG 2.2 SC 1.1.1: an <img> with no text alternative is invisible to a screen reader and announced only as “image” or by its file name. The default remediation advice — “add alt text” — is correct but unautomatable, because the right text depends on the image’s role in the page, which lives in a designer’s head, not the source.

This is why a naive codemod that inserts alt="" everywhere is worse than the violation it fixes. An empty alt tells assistive technology “this image is decorative, skip it” — which is a lie for a product photo or an infographic, and now the scanner passes while real users get nothing. The codemod’s job is therefore to distinguish the two cases conservatively and, when it cannot tell, to insert a placeholder that keeps the build red so a human is forced to look.

Configuration

The transform below handles both <img> and a framework <Image> element. It treats an image as decorative only when there is a strong signal (a role="presentation", aria-hidden, or a path under a known /decorative/ or /icons/ directory); everything else is assumed meaningful and gets a flagged placeholder.

// codemods/remediate-alt.js
const DECORATIVE_HINTS = ["/decorative/", "/icons/", "/backgrounds/"];

module.exports = function transformer(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  // Match both <img> and <Image> opening elements
  root
    .find(j.JSXOpeningElement)
    .filter((path) => {
      const n = path.node.name;
      return n.type === "JSXIdentifier" && (n.name === "img" || n.name === "Image");
    })
    .forEach((path) => {
      const attrs = path.node.attributes.filter((a) => a.type === "JSXAttribute");
      const get = (name) => attrs.find((a) => a.name.name === name);

      const altAttr = get("alt");
      const hasAria = get("aria-label") || get("aria-labelledby");
      const ariaHidden = get("aria-hidden");
      const roleAttr = get("role");

      // Already named (non-empty alt or aria-*) → leave untouched
      if (hasAria) return;
      if (altAttr && altAttr.value && altAttr.value.value !== "") return;
      // Pre-existing empty alt → author already declared it decorative, respect it
      if (altAttr && altAttr.value && altAttr.value.value === "") return;
      if (altAttr && altAttr.value === null) return; // alt with no value (alt) → leave

      // Decorative signals → safe empty alt
      const srcAttr = get("src");
      const srcStr =
        srcAttr && srcAttr.value && srcAttr.value.type === "Literal"
          ? String(srcAttr.value.value)
          : "";
      const decorativeByPath = DECORATIVE_HINTS.some((h) => srcStr.includes(h));
      const decorativeByRole =
        (roleAttr && roleAttr.value && roleAttr.value.value === "presentation") ||
        (ariaHidden && ariaHidden.value && ariaHidden.value.value === "true");

      if (decorativeByPath || decorativeByRole) {
        if (!altAttr) {
          path.node.attributes.push(
            j.jsxAttribute(j.jsxIdentifier("alt"), j.literal(""))
          );
        }
        return;
      }

      // Meaningful (or unknown) → insert a flagged placeholder that FAILS the scan.
      // data-a11y-todo is a non-rendered marker; the empty alt keeps image-alt failing
      // only when paired with our custom guard (see Validation). We use a sentinel.
      path.node.attributes.push(
        j.jsxAttribute(j.jsxIdentifier("alt"), j.literal("TODO: describe image"))
      );
      path.node.attributes.push(
        j.jsxAttribute(j.jsxIdentifier("data-a11y-todo"), j.literal("alt-text"))
      );
    });

  return root.toSource({ quote: "double" });
};

Run it in dry-run first, then apply:

# Preview every change without writing a single file
npx jscodeshift -t codemods/remediate-alt.js src/ \
  --parser=tsx \   # parse TypeScript + JSX
  --dry --print    # print transformed source, write nothing

# Apply once the diff is reviewed
npx jscodeshift -t codemods/remediate-alt.js src/ --parser=tsx

Validation

The placeholder strategy only works if CI treats data-a11y-todo as a hard failure. The standard image-alt rule passes a non-empty alt, so add a custom check that fails whenever the sentinel attribute is present. This jest-axe test confirms both that decorative images pass and that placeholders are caught.

// codemods/__tests__/remediate-alt.test.js
const { applyTransform } = require("jscodeshift/dist/testUtils");
const transform = require("../remediate-alt");

const run = (src) =>
  applyTransform(transform, { parser: "tsx" }, { source: src });

test("decorative path gets empty alt", () => {
  const out = run(`<img src="/icons/chevron.svg" />`);
  expect(out).toContain('alt=""');
  expect(out).not.toContain("data-a11y-todo");
});

test("meaningful image gets a failing placeholder", () => {
  const out = run(`<img src="/photos/team.jpg" />`);
  expect(out).toContain('data-a11y-todo="alt-text"'); // CI guard will fail on this
});

test("existing empty alt is respected", () => {
  const out = run(`<img src="/photos/x.jpg" alt="" />`);
  expect(out).toBe(`<img src="/photos/x.jpg" alt="" />`); // untouched, idempotent
});

The CI guard is a one-line grep that fails the job whenever the sentinel survives into a build:

# Fail the pipeline if any placeholder alt remains unfilled
! grep -rq 'data-a11y-todo="alt-text"' src/ \
  || { echo "Unfilled alt-text placeholders remain"; exit 1; }

Edge Cases & Conditional Guards

  • Decorative images. A genuinely decorative image (a divider, a background flourish) is correctly served by alt="". The codemod only auto-decides this from strong signals (role="presentation", aria-hidden, or a decorative path); anything ambiguous is routed to the failing branch so a human confirms.
  • Background and CSS images. Images set via background-image in CSS have no <img> node, so this codemod cannot see them. They need a PostCSS-side audit or manual review — note this limitation in the PR body so reviewers do not assume full coverage.
  • CMS-driven src. When src is a runtime expression (src={post.hero}), the path-based decorative hint cannot match, so the image is treated as meaningful and flagged — the safe default. Real alt for CMS images usually belongs in the CMS schema, not the JSX.
  • Already-present empty alt. An author who wrote alt="" deliberately declared the image decorative; the codemod respects that and never overwrites it, keeping the run idempotent.

Pipeline Impact

This codemod intentionally leaves the build red. After it runs, the image-alt rule passes on decorative images, but the data-a11y-todo grep guard fails the job for every meaningful image awaiting real text. That is the desired state: the violation has moved from “invisible in a scan report” to “a blocking checklist item in a PR.” The remediation PR therefore lists exactly which files a human must caption, and merges only once the placeholder grep returns clean. Wire the guard alongside your existing checks from Blocking pull requests on critical accessibility violations so the placeholder failure is a true merge blocker.

Common Pitfalls

  • Inserting alt="" on meaningful images to make the scan green — this hides the violation from assistive technology and is worse than the original failure.
  • Forgetting the data-a11y-todo grep guard, so placeholders silently ship as literal “TODO” alt text to production.
  • Overwriting a deliberate, author-written alt="", breaking a correct decorative declaration.
  • Assuming the codemod covers CSS background-image assets — it only sees <img>/<Image> nodes.
  • Running without --parser=tsx, so TypeScript files error out and meaningful images go unflagged.

FAQ

Why insert a placeholder instead of generating alt text automatically? Because a deterministic codemod cannot know what an image depicts or why it is on the page, and a wrong-but-plausible caption is harder to catch than a blank one. The placeholder makes the missing work visible and blocks merge until a human — who can see the image — writes a correct alternative for WCAG 2.2 SC 1.1.1.

How does the codemod decide an image is decorative? Only from strong, explicit signals: role="presentation", aria-hidden="true", or a src under a known decorative directory. Everything else, including runtime src expressions, is treated as meaningful and flagged. The bias is deliberate — false “meaningful” creates a review task; false “decorative” hides content from users.

Can I combine this with an AI step to draft the alt text? Yes — let the codemod stage the placeholder, then have an AI step propose candidate captions in the PR description for a human to accept or rewrite. Keep generated text out of the committed source until reviewed; see the AI-assisted remediation guidance in the parent section.