How to check if a form is accessible
Read the accessible name of every control, not the label elements. Filter Accessibility.getFullAXTree to the form roles and print the name, the source it came from, and the description. On the form below nine label elements cover ten controls, and one control still reaches the tree with an empty name.
Why check this
Run this on every form before release, and again after a component library upgrade changes what an input is made of. A form is where a product loses money when the accessibility is wrong, because a control nobody can address is a step nobody can complete.
The failure it catches is a field with a label on screen and no name in the tree. The label sits next to the input, a sighted user reads them as a pair, and the association was never written. Assistive technology announces an edit field with no name. Voice control cannot be told to click it. A Playwright locator asking for the field by label does not find it, so the suite loses the control at the same moment the user does.
Prerequisites
- Node 22 and Chrome on the same machine.
npm i puppeteer-coreinstalls the driver only: it ships no browser and drives the Chrome already installed. - The
ax.mjshelper from How to check heading structure of a page, saved next to the scripts below. - A form with the defects planted. This one has five: a label with no association, a field named only by its placeholder, an error message attached to nothing, a group with no name, and a required field whose hint is attached correctly for contrast. Save it as
form-demo.mjs, on a port nothing else is using, and stop it afterwards by its PID.
// form-demo.mjs - a signup form with five planted naming defects. node form-demo.mjs
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Form demo</title><style>
body{font:16px system-ui;margin:2rem;max-width:30rem} .field{margin:.8rem 0}
.err{color:#b91c1c}
</style></head><body>
<h1>Create an account</h1>
<form>
<div class="field"><label>Full name</label><input id="name"></div>
<div class="field"><label for="email">Email</label><input id="email" type="email"></div>
<div class="field"><input id="phone" placeholder="Phone number"></div>
<div class="field"><label for="pwd">Password</label><input id="pwd" type="password" required
aria-describedby="pwd-help"><small id="pwd-help">At least 12 characters.</small></div>
<div class="field"><label for="card">Card</label><input id="card" aria-invalid="true">
<span class="err">Card number is not valid</span></div>
<div class="field"><label for="zip">Postcode</label><input id="zip" aria-invalid="true"
aria-describedby="zip-err"><span class="err" id="zip-err">Postcode is not valid</span></div>
<fieldset><legend>Plan</legend>
<label><input type="radio" name="plan" value="free"> Free</label>
<label><input type="radio" name="plan" value="pro"> Pro</label>
</fieldset>
<div class="field" role="group">
<label><input type="checkbox" name="opt" value="news"> Newsletter</label>
<label><input type="checkbox" name="opt" value="beta"> Beta program</label>
</div>
<button>Sign up</button>
</form>
</body></html>`;
createServer((_, res) => res.end(page)).listen(9154, () => console.log('http://localhost:9154/'));
- The figures come from one capture, on one machine, with Chrome 152.0.7977.76.
- WCAG 1.3.1 Info and Relationships, WCAG 3.3.2 Labels or Instructions and WCAG 4.1.2 Name, Role, Value are the three criteria this check maps to.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node form-demo.mjshttp://localhost:9154/ - Step 2.
Run the markup check first, so you have something to compare the tree against.
// label-markup.mjs - node label-markup.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { console.log(await s.page.evaluate(() => { const labels = document.querySelectorAll('label'); const fields = [...document.querySelectorAll('input,select,textarea')]; const linked = fields.filter((f) => document.querySelector(`label[for="${f.id}"]`) || f.closest('label')); return [ ` <label> elements: ${labels.length}`, ` form controls: ${fields.length}`, ` controls a label element points at or wraps: ${linked.length}`, ` controls with neither: ${fields.filter((f) => !linked.includes(f)).map((f) => '#' + (f.id || f.name)).join(', ')}`, ].join('\n'); })); } finally { await s.close(); }node label-markup.mjs http://localhost:9154/<label> elements: 9 form controls: 10 controls a label element points at or wraps: 8 controls with neither: #name, #phoneNine labels, ten controls, two named as suspects. Hold that list against step 3.
- Step 3.
Read the computed name of every control, the source the name came from, and the description attached to it.
// form-names.mjs - node form-names.mjs <url> import { open } from './ax.mjs'; export async function controls(s) { const { nodes } = await s.cdp.send('Accessibility.getFullAXTree'); const roles = ['textbox', 'checkbox', 'radio', 'combobox', 'button', 'group']; const out = []; for (const n of nodes) { if (!roles.includes(n.role?.value) || n.ignored) continue; const { node } = await s.cdp.send('DOM.describeNode', { backendNodeId: n.backendDOMNodeId }); const attrs = {}; for (let i = 0; i < (node.attributes ?? []).length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1]; const props = Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value?.value])); out.push({ id: attrs.id ?? attrs.name ?? '-', role: n.role.value, name: n.name?.value ?? '', from: (n.name?.sources ?? []).find((x) => x.value?.value)?.type ?? '(nothing)', description: n.description?.value ?? '', required: !!props.required, invalid: props.invalid && props.invalid !== 'false' ? props.invalid : '', }); } return out; } export const fmt = (c) => ` ${('#' + c.id).padEnd(8)} ${c.role.padEnd(9)} name=${JSON.stringify(c.name).padEnd(18)} from=${c.from}` + `${c.required ? ' required' : ''}${c.invalid ? ' invalid=' + c.invalid : ''}` + `${c.description ? ' description="' + c.description + '"' : ''}`; if (process.argv[1].endsWith('form-names.mjs')) { const s = await open(process.argv[2]); try { for (const c of await controls(s)) console.log(fmt(c)); } finally { await s.close(); } }node form-names.mjs http://localhost:9154/#- group name="Plan" from=relatedElement #- group name="" from=(nothing) #- button name="Sign up" from=contents #name textbox name="" from=(nothing) #email textbox name="Email" from=relatedElement #phone textbox name="Phone number" from=placeholder #pwd textbox name="Password" from=relatedElement required description="At least 12 characters." #card textbox name="Card" from=relatedElement invalid=true #zip textbox name="Postcode" from=relatedElement invalid=true description="Postcode is not valid" #plan radio name=" Free" from=relatedElement #plan radio name=" Pro" from=relatedElement #opt checkbox name=" Newsletter" from=relatedElement #opt checkbox name=" Beta program" from=relatedElementOne control has no name:
#name, the one with the visible label.#phoneis named, by its placeholder.#cardis marked invalid and carries no description, so the error text beside it belongs to nobody.#zipis the same field done correctly. The secondgroupis the checkbox pair, which has no legend and no name. - Step 4.
Type into the placeholder-named field and read the name again.
// placeholder-name.mjs - node placeholder-name.mjs <url> <selector> import { open } from './ax.mjs'; import { controls } from './form-names.mjs'; const [url, sel] = process.argv.slice(2); const s = await open(url); const read = async (when) => { const c = (await controls(s)).find((x) => x.from === 'placeholder'); const dom = await s.page.evaluate((q) => { const e = document.querySelector(q); return `value=${JSON.stringify(e.value)} placeholder-painted=${e.value === ''}`; }, sel); console.log(` ${when.padEnd(13)} name=${JSON.stringify(c?.name ?? '')} ${dom}`); }; try { await read('before typing'); await s.page.type(sel, '020 7946 0018'); await read('after typing'); } finally { await s.close(); }node placeholder-name.mjs http://localhost:9154/ "#phone"before typing name="Phone number" value="" placeholder-painted=true after typing name="Phone number" value="020 7946 0018" placeholder-painted=falseThe name survives the typing. The painted text does not. Read the next step before you file this as a pass.
- Step 5.
Apply the two fixes in the live page and read the same two controls again, so the change is attributable.
// form-fix.mjs - node form-fix.mjs <url> import { open } from './ax.mjs'; import { controls, fmt } from './form-names.mjs'; const s = await open(process.argv[2]); const show = async (when) => { console.log(` ${when}`); for (const c of await controls(s)) if (['name', 'card'].includes(c.id)) console.log(' ' + fmt(c)); }; try { await show('as shipped:'); await s.page.evaluate(() => { document.querySelector('label').setAttribute('for', 'name'); const err = document.querySelector('#card + .err'); err.id = 'card-err'; document.querySelector('#card').setAttribute('aria-describedby', 'card-err'); }); await show('after for="name" and aria-describedby="card-err":'); } finally { await s.close(); }node form-fix.mjs http://localhost:9154/as shipped: #name textbox name="" from=(nothing) #card textbox name="Card" from=relatedElement invalid=true after for="name" and aria-describedby="card-err": #name textbox name="Full name" from=relatedElement #card textbox name="Card" from=relatedElement invalid=true description="Card number is not valid"One
forattribute produced the name, and onearia-describedbymoved the error text onto the field. Neither changed a pixel.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| name="" from=(nothing) | Nothing gives the control a name | Point a label at it with for, or wrap the control in the label |
| from=placeholder | The only name is the placeholder | Add a real label. Keep the placeholder for an example value |
| from=relatedElement | A label or aria-labelledby supplied it | Correct. Check the text is the one on screen |
| invalid=true with no description | The error message is attached to nothing | Give the message an id and point aria-describedby at it |
| group name="" | A set of related controls has no name | Use fieldset with legend, or aria-label on the group |
| A name with a leading space | The name came from the text around a wrapped control | Harmless, and a sign the label wraps rather than points |
Common mistakes
What to check next
- How to check accessibility tree: the full tree this check filters down to the form controls.
- How to check aria labels: the other ways a name is supplied, and the ways they fail to apply.
- How to check landmark regions of a page: the unnamed
formlandmark this page sits inside. - How to test keyboard navigation on a website: whether the named controls can be reached in the first place.
FAQ
How do I check form labels?
Read the computed name of each control, as step 3 does, and treat from=(nothing) as the defect. A label element in the markup proves nothing on its own: on the demo page a visible label sits next to a control whose name is empty, because the association was never written.
How do I test form error messages for accessibility?
Trigger the error, then read the field's description and its invalid state. A message that is only next to the field produces invalid=true with an empty description, as #card does. Point aria-describedby at the message id, and the text arrives with the field.
Is a placeholder a label?
No. It supplies a name, and step 4 shows that name surviving after the user types, while the painted text disappears. The field is then unlabelled for anyone looking at the screen. Use a label and keep the placeholder for an example value.
Do checkbox and radio groups need a name?
Yes, when the individual labels only make sense together. fieldset with legend produced group name="Plan" in the capture. The checkbox pair, wrapped in a div with role="group" and no legend, produced group name="".
Can I check this without a browser?
Not for the name. Name computation walks aria-labelledby, aria-label, the associated label, the placeholder and the title in order, and resolves each against the live DOM. A static parser can find missing for attributes, which is a first filter, not the answer.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny