Give it a try
-
To see your bookmarks bar, press the keys
(Ctrl/Cmd + Shift + B). - Drag the "SpotCheck - Input Label Checker" link to your bookmarks bar or bookmark folder.
- Open any page, then click the bookmark to run it.
- 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.
●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.
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.
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.
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
forandidpair 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, andtitleare 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.titleis the weakest of the three and should be a last resort. -
Buttons are skipped.
buttonelements and inputs of type submit, reset, button, and image take their name from their content,value, oraltinstead of a label. -
Hidden inputs are skipped, and a
labelwith no text counts as no label at all. -
Grouped controls additional verification. Radio and checkbox
groups also need a
fieldsetand alegend, which this tool does not cover. -
Custom widgets are also out of scope. Anything built from
divelements withrole="textbox"orcontenteditableis not evaluated.