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

// 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/'));

Steps

  1. Step 1.

    Start the demo server and leave it running in its own terminal.

    node form-demo.mjs
    
    http://localhost:9154/
  2. 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, #phone

    Nine labels, ten controls, two named as suspects. Hold that list against step 3.

  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=relatedElement

    One control has no name: #name, the one with the visible label. #phone is named, by its placeholder. #card is marked invalid and carries no description, so the error text beside it belongs to nobody. #zip is the same field done correctly. The second group is the checkbox pair, which has no legend and no name.

  4. 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=false

    The name survives the typing. The painted text does not. Read the next step before you file this as a pass.

  5. 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 for attribute produced the name, and one aria-describedby moved 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

Sign: The markup check names two suspects, the tree names one, and the two lists are treated as the same finding.Cause: The markup pass flagged #name and #phone. In the tree, #phone has a name and #name does not. A ticket that says no label on #phone gets closed as invalid by a developer who reads the tree, and the real defect there, a label that exists only until the user types, never gets filed. Compare the two lists rather than picking one.
Sign: A placeholder-only field passes an automated name check, so it is recorded as fixed.Cause: The accessible name is computed from the placeholder attribute, and the attribute does not change when the user types. The capture in step 4 shows name=Phone number both before and after, with value set on the second read. What disappears is the painted text: the sighted user loses the only label on the field the moment they start filling it, which is a 3.3.2 failure that no name check can see.
Sign: An error message is visible next to a field, the field is marked invalid, and nothing announces the message.Cause: aria-invalid sets the state and attaches no text. On #card the tree shows invalid=true and an empty description, while the message sits beside it in the DOM as ordinary text. #zip is the same field with aria-describedby, and its message arrives as the description. Proximity in the layout carries nothing.

What to check next

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.

intermediate12 minpublished updated Maks Verny