axe "IDs used in ARIA and labels must be unique"
A duplicated id referenced by aria-labelledby, aria-describedby, or label resolves to the wrong element, mistargeting assistive tech.
What you see
IDs used in ARIA and labels must be unique Multiple elements with the same id attribute: billing-name Element: <input id="billing-name">
What’s actually happening
Two or more elements share an id, and at least one of those ids is the target of a <label for>, aria-labelledby, aria-describedby, or aria-controls. ID references in the DOM resolve to the first matching element in document order — so every reference points at that one node and the rest get nothing. A field picks up the wrong label, an error message ends up describing the wrong input, or aria-controls toggles the wrong panel. To a screen reader user the form reads as mislabeled or unlabeled; the visual UI gives no hint anything is off.
Common causes
- A server-side partial, web component, or React component that hardcodes an id (id="email", id="search") and renders more than once on the page.
- A list or table built in a loop emitting the same id on every row instead of appending the index or record key.
- Copy-pasted markup that duplicated a block along with its ids and the for/aria-labelledby pointing at them.
- Two independent widgets that happened to pick the same generic id — "title", "description", "close" — and landed on the same page.
- A modal or off-canvas template cloned into the DOM while a hidden original with identical ids is still present.
How to fix it
- Find the duplicates and what references themIn the console: const ids={}; document.querySelectorAll('[id]').forEach(e=>{ids[e.id]=(ids[e.id]||0)+1}); console.table(Object.entries(ids).filter(([,n])=>n>1)). Then grep the codebase for for=, aria-labelledby=, aria-describedby= and aria-controls= using those id values to see exactly what breaks when you rename.
- Make ids unique per instanceAnywhere a component or loop can render twice, suffix the id with something stable — the record key, the loop index, a generated id. In React, reach for the useId() hook; in a template engine, append {{ index }} or the row's primary key. Update the matching for/aria-* attribute in the same edit so the pair stays in sync.
- Wrap the label when the relationship is one-to-oneFor a single control, wrap it in its label — <label>Email <input></label> — and drop the for/id pairing entirely. That removes one whole source of duplicate-id bugs. Reserve aria-labelledby and aria-describedby for cases where the referenced text genuinely lives elsewhere in the DOM.
- Don't leave a hidden duplicate in the DOMIf you clone a template into a live region, either remove the original or strip the ids off the inert copy. A display:none element with a duplicate id still counts — the rule fires regardless of visibility, because reference resolution doesn't care whether the node is shown.
- Re-scan and confirm the label reaches the right controlAfter renaming, focus each control with a screen reader and check it announces the label and description you intended. The scanner clears once ids are unique, but only assistive tech proves the right text lands on the right element.
Stop it recurring
Generate ids programmatically — useId, a counter, or the record key — so no two instances of a component can collide.