Who it affects
Someone using a screen reader focuses the sort code field and is told 16 digits, no spaces, which is the card number hint. They type the wrong thing and get an error.
Someone using voice control says the label of the second field and the first one receives focus, because both labels point at the same id.
Someone who clicks labels to focus fields clicks the second label and the cursor jumps to the first field.
Broken versus fixed
Both examples are live and rendered in a sandboxed frame. The broken one is deliberately broken.
Both hints are right there on screen. On the sort code field a screen reader announces: Sort code, edit text, 16 digits, no spaces. The browser took the first element with id="hint".
Announced on the sort code field: Sort code, edit text, 6 digits, like 12-34-56. Each hint has its own id and each field points at its own hint.
Show the markup behind both examples
<style>body{font-family:system-ui;padding:16px;margin:0;font-size:14px}label{display:block;margin:8px 0 2px}input{padding:5px;width:180px}.h{font-size:12px;color:#718096;margin-top:2px}</style>
<label for="card">Card number</label>
<input id="card" inputmode="numeric" aria-describedby="hint">
<div class="h" id="hint">16 digits, no spaces</div>
<label for="sort">Sort code</label>
<input id="sort" inputmode="numeric" aria-describedby="hint">
<div class="h" id="hint">6 digits, like 12-34-56</div>
<style>body{font-family:system-ui;padding:16px;margin:0;font-size:14px}label{display:block;margin:8px 0 2px}input{padding:5px;width:180px}.h{font-size:12px;color:#718096;margin-top:2px}</style>
<label for="card">Card number</label>
<input id="card" inputmode="numeric" aria-describedby="card-hint">
<div class="h" id="card-hint">16 digits, no spaces</div>
<label for="sort">Sort code</label>
<input id="sort" inputmode="numeric" aria-describedby="sort-hint">
<div class="h" id="sort-hint">6 digits, like 12-34-56</div>
Test it by hand in two minutes
- In Chrome DevTools, open the Console and run document.querySelectorAll('[id="hint"]').length for any id you suspect. Anything above 1 is a duplicate.
- For a whole page, search the source for id=" and look for repeats; component templates rendered in a loop are the usual culprit.
- Turn on VoiceOver (Cmd+F5) or NVDA and Tab through the form. Listen to the hint or label on each field and compare it with the text next to the field on screen.
- Click each visible label. The field that gets focus should be the one beside it.
The fix
<input aria-describedby="hint">
<div id="hint">16 digits, no spaces</div>
<input aria-describedby="hint">
<div id="hint">6 digits, like 12-34-56</div>
<input aria-describedby="card-hint">
<div id="card-hint">16 digits, no spaces</div>
<input aria-describedby="sort-hint">
<div id="sort-hint">6 digits, like 12-34-56</div>
Make every id unique, then update every reference that points at it. In a component rendered more than once, build the id from something unique, like the field name or an index.
Repeated component with a fixed id
<label for="qty">Quantity</label><input id="qty">
<label for="qty">Quantity</label><input id="qty">
<label for="qty-1">Quantity</label><input id="qty-1">
<label for="qty-2">Quantity</label><input id="qty-2">
The second label now focuses the second field. Generate the suffix in your template loop.
Two headings both named by aria-labelledby
<h2 id="title">Delivery</h2>
<section aria-labelledby="title">...</section>
<h2 id="title">Returns</h2>
<section aria-labelledby="title">...</section>
<h2 id="delivery">Delivery</h2>
<section aria-labelledby="delivery">...</section>
<h2 id="returns">Returns</h2>
<section aria-labelledby="returns">...</section>
Both regions were being announced as Delivery.
Common mistakes
- Renaming one id and forgetting to update the aria-describedby or for= that referenced it.
- Adding a random suffix on every render, so the id changes and references break on re-render.
- Removing the id from the second element, which leaves the second field with no hint at all.
- Assuming the problem is harmless because the page looks and submits fine.
- Fixing the duplicate in the browser inspector but not in the template that generates it.
WCAG criteria this rule maps to
axe-core tags this rule as WCAG 2.2 level A. It is one of the checks for:
- 4.1.2 Name, Role, Value (level A). Official W3C text.
The scanner can see an id that appears more than once and is referenced by an ARIA attribute or a label. It cannot tell which of the two elements each reference was meant for, so decide that from the layout. Automated tools find only part of the accessibility problems on a page; a pass on this rule is not a pass on the criterion.
Related rules
aria-valid-attr-value
Each ARIA attribute accepts a particular kind of value: true or false, a number, a word from a short list, or the id of another element.
A
Form fields must have labels
label
Every input and textarea needs a label that is programmatically tied to it, not just sitting nearby.
A
A form field should have one label, not several
form-field-multiple-labels
When two or more label elements have a for attribute pointing at the same field, screen readers have to pick.
A
ARIA input fields must have an accessible name
aria-input-field-name
When a div or span is given a form role such as textbox, combobox, listbox, slider or spinbutton, assistive technology treats it as a field.