Give it a try

  1. To see your bookmarks bar, press the keys (Ctrl/Cmd + Shift + B) .
  2. Drag the "SpotCheck - Input Label Checker" link to your bookmarks bar or bookmark folder.
  3. Open any page, then click the bookmark to run it.
  4. Click it again or click the close button in the popup to clear the results.

Example of the popup that will appear in the bottom right corner of your browser with a summary of the results.

Input Label Checker

2 explicit

2 implicit

1 no label

What the colors mean

Each control gets an outline and a badge. The color says how the control is named, not just whether it is named.

Explicit

The control is named by a label whose for attribute matches the control's id. This is the most durable association, and it survives markup being moved around.

Implicit

The control is wrapped in a label with no for attribute, or it is named only by aria-label, aria-labelledby, or title. It works today, but it is easier to break.

No label

Nothing gives the control an accessible name. A placeholder does not count; it disappears as soon as someone starts typing.

Try it here

Run the check against the sample markup below without leaving this page.

Applicable WCAG 2.2 success criteria

Labeling touches several criteria at once. These are the ones this check speaks to most directly.

  • 1.3.1
    Info and Relationships (Level A)

    The relationship between a label and its control has to be exposed in the markup, not just implied by visual proximity.

  • 2.4.6
    Headings and Labels (Level AA)

    Once a label exists, it still has to describe what the field is asking for.

  • 2.5.3
    Label in Name (Level A)

    When a visible label is present, the accessible name has to contain that same text so speech input users can target the field.

  • 3.3.2
    Labels or Instructions (Level A)

    Every control that needs user input needs a label or instructions telling people what to enter.

  • 4.1.2
    Name, Role, Value (Level A)

    A form control with no accessible name is announced by its role alone, which tells a screen reader user nothing about the field.

Read the source

The bookmarklet contains the TL;DR version of the code. Here's the readable version.

(function () {
  var w = window;
  var d = document;
  var STATE_KEY = "__a11yInputLabelChecker";

  // Toggle behavior: running the bookmarklet again clears the previous run.
  if (w[STATE_KEY]) {
    w[STATE_KEY].clear();
    return;
  }

  var COLORS = {
    explicit: "#1a7d4f",
    implicit: "#8b6800",
    missing: "#be412a"
  };

  // Lighter variants of the same hues, for use on the dark summary panel.
  var DOTS = {
    explicit: "#3fbf7f",
    implicit: "#e0b84f",
    missing: "#e06a4f"
  };

  var BADGES = {
    explicit: "EXPLICIT LABEL",
    implicit: "IMPLICIT LABEL",
    missing: "NO LABEL"
  };

  // Buttons get their accessible name from value or alt, not from a label,
  // so they are out of scope. Hidden inputs are not perceivable at all.
  var SKIP_TYPES = ["hidden", "submit", "reset", "button", "image"];

  var records = [];
  var counts = { explicit: 0, implicit: 0, missing: 0 };
  var panel = null;

  function normalize(value) {
    return (value || "").replace(/\s+/g, " ").trim();
  }

  function textFrom(el) {
    return el ? normalize(el.textContent) : "";
  }

  // Older engines do not expose HTMLInputElement.labels, so fall back to
  // querying for a matching for/id pair plus the nearest wrapping label.
  function labelsFor(el) {
    if (el.labels && el.labels.length) {
      return Array.prototype.slice.call(el.labels);
    }
    var found = [];
    if (el.id) {
      Array.prototype.slice.call(d.querySelectorAll("label")).forEach(function (label) {
        if (label.getAttribute("for") === el.id) {
          found.push(label);
        }
      });
    }
    var wrapping = el.closest ? el.closest("label") : null;
    if (wrapping && found.indexOf(wrapping) === -1) {
      found.push(wrapping);
    }
    return found;
  }

  function ariaLabelledByText(el) {
    var ids = normalize(el.getAttribute("aria-labelledby"));
    if (!ids) {
      return "";
    }
    return normalize(
      ids
        .split(" ")
        .map(function (id) {
          return textFrom(d.getElementById(id));
        })
        .join(" ")
    );
  }

  // Explicit wins over implicit; ARIA naming is reported as implicit because
  // there is no label element tying visible text to the control.
  function classify(el) {
    var hasExplicit = false;
    var hasImplicit = false;

    labelsFor(el).forEach(function (label) {
      if (!textFrom(label)) {
        return;
      }
      if (el.id && label.getAttribute("for") === el.id) {
        hasExplicit = true;
      } else if (label.contains(el)) {
        hasImplicit = true;
      }
    });

    if (hasExplicit) {
      return "explicit";
    }
    if (hasImplicit) {
      return "implicit";
    }
    if (ariaLabelledByText(el)) {
      return "implicit";
    }
    if (normalize(el.getAttribute("aria-label"))) {
      return "implicit";
    }
    if (normalize(el.getAttribute("title"))) {
      return "implicit";
    }
    return "missing";
  }

  function makeBadge(kind) {
    var badge = d.createElement("span");
    badge.setAttribute("data-a11y-label-check", "badge");
    badge.textContent = BADGES[kind];
    // The control's parent may be a grid or flex container on the host page,
    // which would otherwise stretch the badge to fill its track.
    badge.style.cssText = [
      "display:inline-block",
      "width:fit-content",
      "justify-self:start",
      "margin:0 0 0 4px",
      "padding:2px 6px",
      "border-radius:4px",
      "background:" + COLORS[kind],
      "color:#fff",
      "font:500 14px/1.2 system-ui,-apple-system,sans-serif",
      "letter-spacing:0.04em",
      "vertical-align:middle",
      "position:relative",
      "z-index:2147483646"
    ].join(";");
    return badge;
  }

  function mark(el) {
    var kind = classify(el);
    var record = {
      el: el,
      outline: el.style.outline,
      offset: el.style.outlineOffset,
      badge: makeBadge(kind)
    };

    counts[kind] += 1;
    el.style.outline = "3px solid " + COLORS[kind];
    el.style.outlineOffset = "2px";
    el.insertAdjacentElement("afterend", record.badge);
    records.push(record);
  }

  function makeCountLine(kind, word) {
    var line = d.createElement("p");
    var dot = d.createElement("span");

    line.style.cssText = "margin:0;padding:0";
    dot.textContent = "\u25cf";
    dot.style.cssText = "color:" + DOTS[kind] + ";margin-right:6px";
    line.appendChild(dot);
    line.appendChild(d.createTextNode(counts[kind] + " " + word));
    return line;
  }

  function makePanel() {
    var wrapper = d.createElement("div");
    var heading = d.createElement("strong");
    var close = d.createElement("button");

    wrapper.setAttribute("data-a11y-label-check", "panel");
    wrapper.setAttribute("role", "status");
    wrapper.style.cssText = [
      "position:fixed",
      "right:16px",
      "bottom:16px",
      "max-width:260px",
      "padding:12px 32px 12px 16px",
      "border-radius:6px",
      "background:#1b2430",
      "color:#e7eaed",
      "font:400 14px/1.5 system-ui,-apple-system,sans-serif",
      "box-shadow:0 8px 24px rgba(0,0,0,0.35)",
      "z-index:2147483647"
    ].join(";");

    heading.textContent = "Input Label Checker";
    heading.style.cssText = "display:block;margin-bottom:4px";
    wrapper.appendChild(heading);

    wrapper.appendChild(makeCountLine("explicit", "explicit"));
    wrapper.appendChild(makeCountLine("implicit", "implicit"));
    wrapper.appendChild(makeCountLine("missing", "no label"));

    close.type = "button";
    close.textContent = "\u00d7";
    close.setAttribute("aria-label", "Clear the label check results");
    close.style.cssText = [
      "position:absolute",
      "top:6px",
      "right:6px",
      "padding:2px 6px",
      "border:none",
      "background:transparent",
      "color:#e7eaed",
      "font:16px/1 system-ui,-apple-system,sans-serif",
      "cursor:pointer"
    ].join(";");
    close.addEventListener("click", clear);
    wrapper.appendChild(close);

    return wrapper;
  }

  function clear() {
    records.forEach(function (record) {
      record.el.style.outline = record.outline;
      record.el.style.outlineOffset = record.offset;
      if (!normalize(record.el.getAttribute("style"))) {
        record.el.removeAttribute("style");
      }
      if (record.badge.parentNode) {
        record.badge.parentNode.removeChild(record.badge);
      }
    });
    records = [];
    if (panel && panel.parentNode) {
      panel.parentNode.removeChild(panel);
    }
    panel = null;
    delete w[STATE_KEY];
  }

  Array.prototype.slice
    .call(d.querySelectorAll("input, select, textarea"))
    .filter(function (el) {
      return SKIP_TYPES.indexOf(el.type) === -1;
    })
    .forEach(mark);

  panel = makePanel();
  d.body.appendChild(panel);
  w[STATE_KEY] = { clear: clear };
})();

FYI

  • Explicit and implicit labels both work, but explicit labels are more robust. Explicit is marked green because a for and id pair keeps working when the markup gets restructured, while a wrapping label breaks the moment the control is moved out of it.
  • aria-label, aria-labelledby, and title are counted as gold. They give the control a name, but there typically isn't a visible label tied to it, so sighted speech input users have nothing to say out loud. title is the weakest of the three and should be a last resort.
  • Buttons are skipped. button elements and inputs of type submit, reset, button, and image take their name from their content, value, or alt instead of a label.
  • Hidden inputs are skipped, and a label with no text counts as no label at all.
  • Grouped controls additional verification. Radio and checkbox groups also need a fieldset and a legend, which this tool does not cover.
  • Custom widgets are also out of scope. Anything built from div elements with role="textbox" or contenteditable is not evaluated.