# idの重複でラベルが別の欄を指してしまうフォーム

Source: https://easeweb.dev/learn/duplicate-ids
Topics: ページの構造 > 繰り返し表示する部品のラベルが、それぞれ自分の入力欄を指す (WCAG 1.3.1, 4.1.2)
The fix: フォーム内のidを、置いた場所から作るかフレームワークに生成させて、コピーごとに一意の値にし、各ラベルをそのidに向けます。または、入力欄をlabel要素の中に入れ、idをなくします。どちらの方法でも、各コピーのラベルが自分の入力欄の名前になります。
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

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

紅茶のお店のトップページでは、ニュースレターへの登録を2か所で呼びかけています。ページの中ほどにある「ニュースレターに登録」の帯と、多くの人が探すフッターです。どちらも「メールアドレス」というラベル、入力欄、「登録する」ボタンだけの同じ小さなフォームで、1つの部品として書き、両方の場所で読み込んでいます。保守するフォームが1つで済むので、作りとしては理にかなっています。ところが、この部品は入力欄に `id="email"`、ラベルに `for="email"` を書き出すため、ページの中に同じidを持つ要素が2つできます。

スクリーンリーダーの利用者がフッターまで進み、Tabで入力欄に入ると、「編集テキスト」とだけ聞こえ、何を入力するのかが分かりません。ページ中ほどの欄に戻ると、今度は「メールアドレス メールアドレス」と読み上げられます。目で見て操作する人が、欄にカーソルを入れようとしてフッターの「メールアドレス」という文字をクリックすると、ページが中ほどの帯までスクロールし、カーソルは帯の欄に入ります。もう一度下までスクロールして欄そのものをクリックすれば、入力はできます。フッターのフォームでも、入力と送信はできます。壊れているのは、各ラベルと入力欄の結び付きです。

## 確認できる問題

HTMLがidを文書の中で一意にするよう求めている理由が、ここに表れています。ラベルの `for` は、そのidを持ついちばん近い欄を探すのではなく、ページの中でそのidを持つ最初の要素を指します。そのため、2つのラベルはどちらも帯の欄を指し、帯の欄にはラベルが2つ、フッターの欄には1つもない状態になります。`aria-labelledby`、`aria-describedby`、`aria-controls` も同じく、各idを持つ最初の要素を指します。繰り返す部品の中のヒントやエラーメッセージが別のコピーで読み上げられてしまうのも、このためです。

達成基準1.3.1「情報及び関係性」は、画面で示す関係をコードからも判別できることを求めています。画面では、フッターの「メールアドレス」はフッターの欄の上にありますが、コード上はページの上のほうにある別の欄のものです。達成基準4.1.2「名前（name）・役割（role）及び値（value）」は、すべてのコントロールの名前をコードから判別できることを求めていますが、フッターの欄には名前がまったくありません。ChromeとFirefoxはどちらも、帯の欄を「メールアドレス メールアドレス」、フッターの欄を名前なしとして扱います。WCAG 2.2では、idの重複そのものを不適合としていた達成基準4.1.1「構文解析」が削除されました。その解説ページは、こうした問題の多くが「情報及び関係性」か「名前・役割及び値」に適合しなくなると説明しています。この例もその1つで、idの重複が原因、ラベルがないことが不適合です。

## 利用者への影響

ラベルをクリックすれば、障害の有無にかかわらず、誰でもページが飛んでしまいます。フッターの欄にプレースホルダーがあると、ラベルがないことが見えにくくなります。ほかに名前がないとき、ブラウザーはプレースホルダーを名前として使うため、スクリーンリーダーはそれを読み上げ、問題が分かるのはクリックしたときだけになります。

## 自動チェックとAIの限界

axeのlabelルールは、入力欄のidを指すラベルがあるかを確認します。フッターの欄にはそのようなラベルがあるので、ルールは合格とします。axeはidの重複にも気づきますが、duplicate-id-ariaルールは常に確認候補として出すだけです。idが重複しているだけでは、ラベルがどのコピー向けに書かれたのかが分からないからです。form-field-multiple-labelsルールも、ラベルが複数あるとして、両方の欄を確認候補にします。EaseWebの診断は、同じidを持つ欄を2つ以上のラベルが指し、後ろの欄にラベルがない場合に、フッターの欄を問題として報告します。`aria-labelledby` や `aria-describedby` だけが使う重複したidは、人が確かめる対象として残します。部品を1回だけ表示するテストでは、この問題は起きません。コード生成AIにニュースレターのフォームを頼むと、固定のidを書くことがあります。フォームを2か所目で使うまでは、それで正しく動きます。

## 修正前：同じidのまま、同じフォームを2か所に置く

```html
<section class="signup">
  <h2>ニュースレターに登録</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email">メールアドレス</label>
    <input id="email" name="email" type="email" autocomplete="email">
    <button>登録する</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email">メールアドレス</label>
    <input id="email" name="email" type="email" autocomplete="email">
    <button>登録する</button>
  </form>
</footer>
```

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

- 2つの入力欄が `id="email"` を持つため、2つのラベルはどちらも最初の欄、つまり帯の欄を指します。
- フッターの欄にはラベルがありません。スクリーンリーダーはこの欄を「編集テキスト」、帯の欄を「メールアドレス メールアドレス」と読み上げます。
- フッターの「メールアドレス」をクリックすると、帯の欄にカーソルが入り、ページがそこまでスクロールします。

## 修正後：コピーごとにidを付ける

```html
<section class="signup">
  <h2>ニュースレターに登録</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email-signup">メールアドレス</label>
    <input id="email-signup" name="email" type="email" autocomplete="email">
    <button>登録する</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label for="email-footer">メールアドレス</label>
    <input id="email-footer" name="email" type="email" autocomplete="email">
    <button>登録する</button>
  </form>
</footer>
```

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

1. 部品を読み込む場所ごとに `signup`、`footer` のような値を渡し、部品が書き出すすべてのidに付けます。これで、入力欄はそれぞれ自分のidを持ちます。
2. 各ラベルの `for` を、同じコピーのidに向けます。スクリーンリーダーはどちらの欄も「メールアドレス」と読み上げ、どちらのラベルをクリックしても、その下の欄にカーソルが入ります。
3. `name="email"` はそのままにします。nameはサーバーが受け取る名前で、重複してもかまいません。一意でなければならないのはidだけです。

これで修正前の例は直り、1.3.1と4.1.2を満たします。コンポーネントのフレームワークでは、値を渡す代わりにフレームワークにidを作らせます。ReactとVueでは `useId()`、Svelteでは `$props.id()`、Shopifyのテーマでは、JSONテンプレートから追加したセクションの `section.id` です（同じ静的セクションを2回描くと、ファイル名がidとして重複します）。一覧の中では、商品のidや一覧での順番など、項目ごとに異なる値からidを作ります。

## 修正後：入力欄をラベルの中に入れる

```html
<section class="signup">
  <h2>ニュースレターに登録</h2>
  <form class="signup-form" action="/subscribe" method="post">
    <label>
      メールアドレス
      <input name="email" type="email" autocomplete="email">
    </label>
    <button>登録する</button>
  </form>
</section>
…
<footer>
  …
  <form class="signup-form" action="/subscribe" method="post">
    <label>
      メールアドレス
      <input name="email" type="email" autocomplete="email">
    </label>
    <button>登録する</button>
  </form>
</footer>
```

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

1. 入力欄をlabel要素の中に移します。ラベルは中にある入力欄のラベルになるので、結び付けるためのidはいりません。
2. ラベルの `for` と入力欄の `id` を削除します。`for` が残っていると、中の入力欄よりそちらが優先され、その入力欄にはラベルが付きません。ラベルは、そのidを持つ別の要素を指すか、どこも指さなくなります。
3. CSSでラベルをグリッドにし、文字を欄の上に置いたまま、ボタンを欄の下端にそろえます。

これで修正前の例は直り、1.3.1と4.1.2を満たします。重複するidも残りません。ただし、直るのはラベルだけです。`aria-describedby` で欄に結び付けるヒントやエラーメッセージにはidが必要で、そのidもコピーごとに一意にする必要があります。

## どの方法を選ぶか

どちらの方法でも基準を満たします。コピーごとにidを付ける方法は、マークアップもレイアウトも変えずに済みます。欄にヒントやエラーメッセージなど、idで指すものがほかにもある場合は、どのみち一意のidが必要なので、こちらを使います。入力欄をラベルの中に入れる方法は、重複するものも、そろえておくものも残りません。ラベル以外に欄を指すもののない、この例のような小さなフォームに向いています。代わりにCSSの変更が必要です。ラベルで欄を囲み、それ以外のものには生成したidを使うというように、両方を組み合わせることもできます。

## 実装の判断

同じフォームを2か所に置く場合のほかに、ループの中で使う部品でも起きます。かごの商品ごとにある「ギフト包装」の選択肢を `id="gift-wrap"` で書くと、すべての商品のチェックボックスが同じidになり、どの商品の「ギフト包装」をクリックしても、最初の商品のチェックボックスにチェックが入ります。ページで繰り返し表示する部品は、どれも同じように確認が必要です。

- スマートフォン用と広い画面用に、ページが2回出力するダイアログやドロワー
- それぞれ `aria-controls` で自分のパネルを指す、スマートフォン用とパソコン用の同じメニュー
- サービスの埋め込みコードを2回貼り付けたウィジェット。idはコードの中に固定で書かれている（Mailchimpの埋め込みフォームは `id="mce-EMAIL"` を書き出す）

片方のコピーをCSSで隠しても解決しません。`display: none` で隠したコピーもページの中にあり、それが先にあればラベルはそちらを指します。すると、表示しているコピーのほうにラベルがなくなり、そのラベルをクリックしても何も起きません。表示するコピーだけを出力するか、コピーごとにidを付けてください。

乱数やカウンターで作ったidは、サーバーでの描画とブラウザーでの描画とで値が食い違うことがあり、両方で描画するフレームワークではハイドレーションのエラーになります。React、Vue、Svelteが独自のid関数を用意しているのはそのためです。`Math.random()` ではなく、それらを使ってください。

2つのフォームに同じ `aria-label` を付けると、同じように聞こえるフォームのランドマークが2つできますが、これは別の問題です。繰り返す領域の名前の付け方は、[区別できない複数のナビゲーションランドマーク](/ja/learn/landmark-names)を参照してください。テンプレートで名前を変えたidなど、存在しないidを `for` が指すラベルについては[ラベルがない・表示と食い違うフォームの入力欄](/ja/learn/form-labels)を、存在しないidを指す `aria-labelledby` や `aria-describedby` については[読み上げを誤らせる、無効なARIA](/ja/learn/valid-aria)を参照してください。

## 修正の確認

1. スクリーンリーダーを使い、Tabで帯のメール欄へ、次にフッターのメール欄へ移動する。どちらも「メールアドレス」と1回だけ読み上げられることを確認する。
2. 各欄の上の「メールアドレス」という文字をクリックする。その欄にカーソルが入り、ページの表示位置が変わらないことを確認する。
3. 表示されたページでaxeなどのチェックツールを実行し、duplicate-id-ariaが確認候補を出さないことを確認する。ブラウザーのコンソールで `[...document.querySelectorAll('[id]')].map(e => e.id).filter((id, i, all) => all.indexOf(id) !== i)` を実行すると、2回以上使われているidが一覧になる。
4. ページで2回以上表示するすべての部品について繰り返す。ポップアップの中のフォーム、かごの商品ごとの選択肢、画面の幅ごとに2回出力するダイアログやメニューなど。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。共通の部品を新しい場所で使ったときは、再度確認します。

## 制限

このガイドが扱うのは、2回以上表示する部品が繰り返すidと、それがラベルに与える影響です。別々の要素に手作業でたまたま同じidを付けた場合（片方の名前を変えれば直る）や、存在しないidを指す場合は扱いません。繰り返すフォームの見えるラベルを、場所ごとに「ニュースレター用のメールアドレス」のように変えるべきかどうかは、[何を入力するのか分からない、あいまいなフォームのラベル](/ja/learn/descriptive-labels)で扱います。対象とする支援技術で、操作全体を確認してください。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
