Bulk-Fixing Form Label Associations with jscodeshift

Forms accumulate orphan labels: a <label> and an <input> sit next to each other in the markup, look correct on screen, but carry no programmatic association — so a screen reader announces the input as unlabeled. This page builds a jscodeshift codemod that wires those pairs together by adding htmlFor/id, or by wrapping the control, across an entire codebase in one reviewable pass. It belongs to Codemod-Driven Accessibility Fixes, part of the broader Automated Remediation & Accessibility Fixing Patterns section.

Two success criteria govern this fix. WCAG 2.2 SC 1.3.1 (Info and Relationships) requires that the label-to-control relationship be conveyed programmatically, not just visually. WCAG 2.2 SC 4.1.2 (Name, Role, Value) requires that the control expose an accessible name to assistive technology — which an associated label provides. A scanner reports this as the label rule.

  • Target: a <label> with no htmlFor (or React htmlFor) adjacent to an <input>/<select>/<textarea> with no id.
  • Fix shape: generate a stable id, set the control’s id, and point the label’s htmlFor at it.
  • Never overwrite an existing aria-label, aria-labelledby, or wrapping association.
  • Governing criteria: WCAG 2.2 SC 1.3.1 and SC 4.1.2; scanner rule label.
Label association codemod Before, a label and input have no shared identifier. After the codemod, the label's htmlFor matches the input's generated id, creating a programmatic association. Before <label> Email </label> <input type="email" /> no shared id — unlabeled codemod After label htmlFor="email-1" input id="email-1" programmatic association
The codemod generates one stable id and points the label's htmlFor at it, satisfying SC 1.3.1 and 4.1.2.

Root Cause / Context

A scanner fires the label rule when a form control has no accessible name reachable through any of the supported mechanisms: an associated <label for>, a wrapping <label>, aria-label, or aria-labelledby. Visually adjacent text does not count, because assistive technology computes the name from the accessibility tree, not from pixel proximity. The most common cause is hand-written JSX where developers rely on layout to imply the relationship.

The fix is mechanical — generate a shared identifier and link the two nodes — which makes it ideal for a codemod. The subtlety is not breaking the cases that are already correct. A control may already have an aria-label, the label may already wrap its control, or two controls may collide on a generated id. The transform must detect each of these and bail out rather than “fix” working code.

Configuration

The transform finds <label> elements without an association, locates the nearest sibling control, and wires them together with a deterministic id derived from the control’s name or type plus a per-file counter for uniqueness. Note React uses htmlFor, not for.

// codemods/associate-labels.js
const CONTROLS = new Set(["input", "select", "textarea"]);

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

  const attr = (el, name) =>
    el.attributes.find((a) => a.type === "JSXAttribute" && a.name.name === name);

  const hasName = (el) =>
    attr(el, "aria-label") || attr(el, "aria-labelledby") || attr(el, "id");

  root.find(j.JSXElement, { openingElement: { name: { name: "label" } } }).forEach((labelPath) => {
    const label = labelPath.node;
    const labelOpen = label.openingElement;

    // Skip labels that already associate via htmlFor
    if (attr(labelOpen, "htmlFor")) return;

    // Skip wrapping labels: a control already lives inside this <label>
    const wrapsControl = label.children.some(
      (c) =>
        c.type === "JSXElement" &&
        c.openingElement.name.type === "JSXIdentifier" &&
        CONTROLS.has(c.openingElement.name.name)
    );
    if (wrapsControl) return; // wrapping is a valid association — leave it

    // Find the nearest following sibling that is a control
    const parent = labelPath.parent.node;
    const siblings = parent.children || [];
    const idx = siblings.indexOf(label);
    const control = siblings
      .slice(idx + 1)
      .find(
        (c) =>
          c.type === "JSXElement" &&
          c.openingElement.name.type === "JSXIdentifier" &&
          CONTROLS.has(c.openingElement.name.name)
      );
    if (!control) return; // no nearby control — needs manual review

    const controlOpen = control.openingElement;

    // Respect controls that already expose a name
    if (attr(controlOpen, "aria-label") || attr(controlOpen, "aria-labelledby")) return;

    // Reuse an existing id if present, else generate a stable one
    let id;
    const existingId = attr(controlOpen, "id");
    if (existingId && existingId.value.type === "Literal") {
      id = String(existingId.value.value);
    } else {
      const nameAttr = attr(controlOpen, "name");
      const base =
        nameAttr && nameAttr.value.type === "Literal"
          ? String(nameAttr.value.value)
          : controlOpen.name.name;
      id = `${base}-${++counter}`; // per-file counter avoids collisions
      controlOpen.attributes.push(j.jsxAttribute(j.jsxIdentifier("id"), j.literal(id)));
    }

    labelOpen.attributes.push(j.jsxAttribute(j.jsxIdentifier("htmlFor"), j.literal(id)));
  });

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

Preview, then apply:

# Dry-run: see every association the codemod would make, write nothing
npx jscodeshift -t codemods/associate-labels.js src/ \
  --parser=tsx \   # TypeScript + JSX
  --dry --print

# Apply once reviewed
npx jscodeshift -t codemods/associate-labels.js src/ --parser=tsx

Validation

Prove the fix with jest-axe asserting the label rule no longer fires, plus transform-level tests for the skip cases. Render the transformed component and run axe(); a clean result means SC 1.3.1 and 4.1.2 are satisfied for that control.

// codemods/__tests__/associate-labels.test.js
const { render } = require("@testing-library/react");
const { axe, toHaveNoViolations } = require("jest-axe");
const { applyTransform } = require("jscodeshift/dist/testUtils");
const transform = require("../associate-labels");

expect.extend(toHaveNoViolations);

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

test("orphan label and input become associated", () => {
  const out = run(`<form><label>Email</label><input name="email" /></form>`);
  expect(out).toContain('htmlFor="email-1"');
  expect(out).toContain('id="email-1"');
});

test("wrapping label is left untouched", () => {
  const src = `<label>Email<input name="email" /></label>`;
  expect(run(src)).toBe(src); // already a valid association
});

test("aria-labelled input is left untouched", () => {
  const src = `<label>Email</label><input aria-label="Email" />`;
  expect(run(src)).toBe(src);
});

test("rendered output passes the axe label rule", async () => {
  const { container } = render(<form><label htmlFor="email-1">Email</label><input id="email-1" name="email" /></form>);
  expect(await axe(container)).toHaveNoViolations(); // SC 1.3.1 / 4.1.2 satisfied
});

Edge Cases & Conditional Guards

  • Existing aria-label / aria-labelledby. The control is already named; associating a visible label could create a conflicting or duplicate name. The codemod detects either attribute and skips the pair entirely.
  • Wrapping labels. <label>Email <input /></label> is already a valid association by containment. The transform checks for a control child and leaves wrapping labels alone — adding htmlFor would be redundant noise.
  • Custom component inputs. A <TextField /> or <Select /> from a design system is not a native control, so this codemod will not match it — and should not, because the prop that carries the id (inputId, htmlFor, etc.) is component-specific. Route these to a component-aware ts-morph transform or a design-system default instead.
  • Duplicate ids. A naive generator can collide when two email fields share a file. The per-file counter suffix (email-1, email-2) keeps ids unique within a module; if your build mounts the same component multiple times, prefer a runtime useId() over a static id.

Pipeline Impact

After this codemod runs, the scanner’s label rule clears for every native control it associated, so the violation count drops in the verify step of the remediation loop. Because the change is purely additive (new id and htmlFor attributes), the diff is low-risk and review is fast. Run it in the remediation workflow that opens a PR, and let the jest-axe suite act as the gate: if any associated control still reports a label violation, the transform tests fail and the PR is not opened. Pair the PR with the existing pull-request checks from Configuring GitHub Actions for automated WCAG checks so the re-scan runs against the proposed branch.

Common Pitfalls

  • Generating non-unique ids, so two controls share an id and the association points at the wrong field.
  • Writing for instead of htmlFor in JSX — React ignores for, and the association silently fails.
  • Overwriting a control that already has aria-label, producing a conflicting accessible name.
  • Adding htmlFor to a wrapping label, which is redundant and clutters the diff.
  • Assuming the codemod handles custom design-system inputs — it only matches native input/select/textarea.

FAQ

Why associate via htmlFor/id instead of wrapping the input in the label? Both are valid for WCAG 2.2 SC 1.3.1, but htmlFor/id is the lower-risk automated edit: it is purely additive and does not restructure the DOM, so it cannot accidentally move a control out of a layout grid or break sibling styling. Wrapping changes the element tree and is better left to manual refactors.

How does the codemod avoid breaking controls that are already labeled? It checks each control for aria-label, aria-labelledby, an existing htmlFor on the label, and the wrapping-label pattern before doing anything. Any of those means the control already exposes a name, so the transform skips it and the run stays idempotent.

What about custom input components from our design system? This jscodeshift transform only matches native elements. For <TextField />-style components, the right fix is a type-aware ts-morph transform that knows the component’s labeling prop, or — better — an accessible default in the component itself so the association is guaranteed at the source.