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 nohtmlFor(or ReacthtmlFor) adjacent to an<input>/<select>/<textarea>with noid. - Fix shape: generate a stable
id, set the control’sid, and point the label’shtmlForat 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.
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 — addinghtmlForwould 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
emailfields share a file. The per-filecountersuffix (email-1,email-2) keeps ids unique within a module; if your build mounts the same component multiple times, prefer a runtimeuseId()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
idand the association points at the wrong field. - Writing
forinstead ofhtmlForin JSX — React ignoresfor, and the association silently fails. - Overwriting a control that already has
aria-label, producing a conflicting accessible name. - Adding
htmlForto 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.
Related
- Codemod-Driven Accessibility Fixes — the parent guide on writing, dry-running, and gating AST transforms.
- automating alt-text remediation with codemods — a sibling codemod for missing image alt text.
- Configuring GitHub Actions for automated WCAG checks — the CI checks that re-scan the remediation PR.