# Duplicate ids that point labels at the wrong field

Source: https://easeweb.dev/learn/duplicate-ids
Topics: Page structure > Labels in a repeated component name their own field (WCAG 1.3.1, 4.1.2)
The fix: Either give every id in the form a value unique to its copy, built from where the copy is shown or generated by the framework, and point each label at it, or put each field inside its label element and drop the id. Either way, each copy's label names its own field.
Test with: screen reader, mouse
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/parsing.html https://html.spec.whatwg.org/multipage/forms.html#attr-label-for https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label https://react.dev/reference/react/useId https://dequeuniversity.com/rules/axe/4.13/duplicate-id-aria

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A tea shop's home page asks visitors to join its newsletter twice: once in a band halfway down the page, "Join the newsletter", and again in the footer, where many people look for it. Both are the same small form, an "Email" label over a field and a Sign up button, written once as a partial and included in both places. That is sensible: one form to maintain. But the partial writes `id="email"` on its field and `for="email"` on its label, so the page now has two elements with the same id.

A screen reader user who reaches the footer and tabs into the field hears "edit text", and nothing that says what to type. If they go back up to the band, its field is read as "Email Email". A sighted visitor who clicks the word "Email" in the footer, as people do to put the cursor in a field, sees the page scroll back up to the band, with the cursor in the band's field. They scroll down again, click the field itself, and it works. Typing and sending the footer form still work; what is broken is the tie between each label and its field.

## Why it fails

HTML says an id must be unique in its document, and the reason shows here. A label's `for` doesn't look for the nearest field with that id: it takes the first element in the page with it. So both labels point at the band's field, which ends up with two labels, and the footer's field has none. The same goes for `aria-labelledby`, `aria-describedby` and `aria-controls`, which also take the first element with each id, so a hint or an error message in a repeated component ends up read on the wrong copy.

Success Criterion 1.3.1 Info and Relationships asks that relationships shown on screen can also be determined in code. On screen, the footer's "Email" sits over the footer's field; in code, it belongs to another field further up the page. Success Criterion 4.1.2 Name, Role, Value asks that every control's name can be determined in code, and the footer's field has no name at all. Chrome and Firefox both give the band's field the name "Email Email" and the footer's field no name. WCAG 2.2 removed 4.1.1 Parsing, which used to fail duplicate ids as such, and explains that many of those issues "will fail Info and Relationships or Name, Role, Value". This is one of them: the duplicate id is the cause, and the missing label is the failure.

## Who is affected

The jump happens to anyone who clicks a label, with or without a disability. A placeholder in the footer's field can hide the missing label: browsers use it as the name when nothing else gives one, so a screen reader reads the placeholder, and only the click still shows the fault.

## What automation and AI miss

axe's label rule checks that a field has a label pointing at its id, and the footer's field has one, so the rule passes it. axe does notice the shared id, but its duplicate-id-aria rule only ever sends it for review, because a repeated id alone doesn't say which copy a label meant, and its form-field-multiple-labels rule sends both fields for review as having more than one label. EaseWeb's audit reports the footer's field as a finding when two or more labels point at an id that fields share and the later field is left with no label; a shared id used only by `aria-labelledby` or `aria-describedby` stays a lead for a person. Tests that render a component once never meet the problem, and a coding assistant asked for a newsletter form may write a fixed id, which is correct until the form is used a second time.

## Before: the same form, with the same ids, in two places

```html
<section class="signup">
  <h2>Join the newsletter</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email">
    <button>Sign up</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email">
    <button>Sign up</button>
  </form>
</footer>
```

```css
.signup-form {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
}
.signup-form label {
  flex-basis: 100%;
  font-weight: bold;
}
```

- Two fields have `id="email"`, so both labels go to the first one, in the band.
- The footer's field has no label. A screen reader reads it as "edit text", and the band's field as "Email Email".
- Clicking "Email" in the footer puts the cursor in the band's field, and the page scrolls up to it.

## After: an id for each copy

```html
<section class="signup">
  <h2>Join the newsletter</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email-signup">Email</label>
    <input id="email-signup" name="email" type="email" autocomplete="email">
    <button>Sign up</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email-footer">Email</label>
    <input id="email-footer" name="email" type="email" autocomplete="email">
    <button>Sign up</button>
  </form>
</footer>
```

```css
.signup-form {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
}
.signup-form label {
  flex-basis: 100%;
  font-weight: bold;
}
```

1. Give the partial a value for each place it is included, such as `signup` and `footer`, and add it to every id the partial writes. Each field now has an id of its own.
2. Point each label's `for` at the id in its own copy. A screen reader reads each field as "Email", and a click on either label puts the cursor in the field under it.
3. Leave `name="email"` as it is. The name is what the server receives, and it may repeat; only ids must not.

This fixes the Before and passes 1.3.1 and 4.1.2. In a component framework, let the framework make the id rather than passing a word in: `useId()` in React and Vue, `$props.id()` in Svelte, or `section.id` for a section added through a Shopify theme's JSON templates (a static section rendered twice keeps its file name as its id). In a list, build the id from something unique to each item, such as its product id or its place in the list.

## After: each field inside its label

```html
<section class="signup">
  <h2>Join the newsletter</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label>
      Email
      <input name="email" type="email" autocomplete="email">
    </label>
    <button>Sign up</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label>
      Email
      <input name="email" type="email" autocomplete="email">
    </label>
    <button>Sign up</button>
  </form>
</footer>
```

```css
.signup-form {
  display: flex;
  align-items: end;
  gap: 8px;
}
.signup-form label {
  display: grid;
  gap: 4px;
  font-weight: bold;
}
```

1. Move the field inside its label element. A label labels the field it contains, so no id is needed to tie them.
2. Remove `for` from the label and `id` from the field. A `for` left on the label wins over the field inside it, so that field gets no label: the label goes to whatever element has the id, or to nothing.
3. In the CSS, lay the label out as a grid, so the word stays above the field, and line the button up with the bottom of the field.

This fixes the Before and passes 1.3.1 and 4.1.2, and there is no id left to repeat. It covers the label only: a hint or an error message tied to the field with `aria-describedby` still needs an id, and that id still has to be unique to its copy.

## Which option to use

Both pass. An id for each copy keeps the markup and the layout as they are, and it is the one to use when the field also has a hint, an error message or anything else that points at it by id, since those need unique ids anyway. A field inside its label leaves nothing to repeat and nothing to keep in step, which suits a small form like this one, with nothing pointing at the field but its label; it costs a change to the CSS. The two can also be combined: the label around the field, and generated ids for everything else.

## Implementation decisions

The same form in two places is one case, and a component used in a loop is another. A gift-wrap option on every basket item, written with `id="gift-wrap"`, gives every item's checkbox the same id, and a click on any item's "Gift wrap" ticks the first item's box. Each repeated component on a page needs checking the same way:

- a dialog or a drawer the page renders twice, such as one for the phone layout and one for wider screens;
- a mobile and a desktop copy of the same menu, each with `aria-controls` pointing at its own panel;
- a widget pasted twice from a provider's embed code, whose ids are fixed in the snippet (Mailchimp's embedded form writes `id="mce-EMAIL"`).

Hiding one copy with CSS doesn't solve it. A copy hidden with `display: none` is still in the page, and a label still finds it if it comes first; the visible copy is then the one left without a label, and a click on its label does nothing. Render only the copy that is shown, or give each its own ids.

Ids made from a random number, or from a counter, can differ between the server's render and the browser's, which causes hydration errors in frameworks that render on both. That is why React, Vue and Svelte provide their own id functions; use them rather than `Math.random()`.

Two copies of a form given the same `aria-label` become two form landmarks that sound the same, which is a separate question; see [navigation landmarks that all sound the same](/learn/landmark-names) for naming repeated regions. A label whose `for` points at an id that doesn't exist at all, such as one renamed in a template, belongs to [form fields with missing or mismatched labels](/learn/form-labels), and an `aria-labelledby` or `aria-describedby` that points at a missing id to [invalid ARIA that misleads screen readers](/learn/valid-aria).

## Verify the fix

1. With a screen reader, Tab to the email field in the band, then to the one in the footer. Each is read as "Email", once.
2. Click the word "Email" above each field. The cursor goes to that field, and the page stays where it is.
3. Run axe, or another checker, on the page as rendered, and confirm that duplicate-id-aria sends nothing for review. In the browser's console, `[...document.querySelectorAll('[id]')].map(e => e.id).filter((id, i, all) => all.indexOf(id) !== i)` lists any id used twice.
4. Repeat for every component the page shows more than once: forms in a pop-up, options on basket items, dialogs and menus drawn twice for different screen sizes.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever a shared component is used in a new place.

## Limits

This guide covers ids repeated by a component shown more than once, and what that does to labels. It doesn't cover two different elements that happen to be given the same id by hand, which is fixed by renaming one of them, or a reference to an id that is missing. Whether a repeated form also needs a different visible label in each place, such as "Email for the newsletter", is covered in [vague form labels that don't say what to enter](/learn/descriptive-labels). Test the complete journey with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
