# バッジしか変わらない「カートに入れる」ボタン

Source: https://easeweb.dev/learn/action-confirmations
Topics: ライブ更新 > カートへの追加やリンクのコピーなどの結果が読み上げられる (WCAG 4.1.3)
The fix: 結果を言葉にして、ボタンを押す前からページにあるステータス領域に書き込みます。ボタンのそばにrole="status"の短い行を表示するか、見た目はそのままにしてページのレイアウトに視覚的に隠したステータス領域を置くか、最小の変更なら、カートの件数の数字に言葉を添えて、全体をステータス領域にします。カートへフォーカスを移さないでください。
Test with: screen reader, keyboard, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

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

## 場面

生活雑貨のショップの商品ページに、ホーローマグとその価格、「カートに入れる」ボタンがあります。ヘッダーには買い物袋のアイコンがあり、カートの商品数を示す小さな丸いバッジが付いています。デザインは落ち着いた画面を優先していて、ポップアップもスライドして出るパネルもありません。「カートに入れる」を押すと、1秒ほどしてショップのサーバーが応答したところで、バッジが0から1に変わります。知らせはそれだけで、それまでの1秒ほどは、押したことが受け付けられたかどうかも分かりません。画面が見える人は、視界の端で数字が変わるのに気づきます。スクリーンリーダーの利用者がボタンを押しても、待っている間もそのあとも、何も聞こえません。フォーカスはボタンに残り、名前も「カートに入れる」のままで、バッジはページの反対の端にあります。マグが入ったのか分からないため、もう一度押して2個にしてしまうか、ページを離れてカートを開いて確かめ、元の位置を探しながら戻ってきます。

## 確認できる問題

「カートの商品は1点です」はステータスメッセージです。利用者の操作の結果を伝え、フォーカスを奪いません。達成基準4.1.3は、こうしたメッセージを役割やプロパティによって支援技術に伝え、フォーカスを動かさずに読み上げられるようにすることを求めています。これはW3Cがこの基準の例として挙げている場面そのものです。「カートに入れる」ボタンを押すと、カートのアイコンの近くの表示が「5 items」に変わり、スクリーンリーダーがそれを読み上げる、という例です。このページのバッジは、カートのリンクの中にあるただの `span` です。画面上の数字は変わっても、変化を読み上げるよう支援技術に伝える役割やプロパティがないため、失敗例F103に当たります。ボタンも代わりにはなりません。フォーカスも名前も変わらないので、スクリーンリーダーが新しく読むものがないのです。それに、仮に読まれたとしても「1」だけでは意味が伝わりにくいため、W3Cは「in shopping cart」のような言葉を数字に添えることを勧めています。

## 利用者への影響

バッジは、ページ全体を一度に見ている人に向けて置かれています。縦に長い商品ページでは、ボタンまで来たときにヘッダーが画面の外に出ていることも多く、画面が見える人でも変化を見逃すことがあります。

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

スキャンは、名前のあるボタンと名前のあるリンクを見て、どちらも合格にします。実際のショップで押すと本物のカートに商品が入るため、「カートに入れる」は押しません。そのためバッジの変化を見ることはなく、マークアップにもバッジが結果を伝えるものだとは書かれていません。EaseWebの診断では、「カートに入れる」や「リンクをコピー」のボタンがあるのにライブリージョンが一つもないページを、手作業で確かめる手がかりとして報告します。カートをアクセシブルにするよう頼まれたAIアシスタントは、カートのリンクに「カート、1点」のような `aria-label` を付けることがあります。リンクにたどり着いたときに読まれる名前は分かりやすくなりますが、フォーカスのない要素の名前が変わっても読み上げられません。カートのリンクへフォーカスを移す例もありますが、見ていた商品から利用者を引き離してしまいます。スクリーンリーダーを動かしてボタンを押して、初めて何が読まれるかが分かります。

## 修正前：黙って変わるバッジ

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">カート</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>ホーローマグ</h1>
  <p class="price">$18</p>
  <button type="button" id="add">カートに入れる</button>
</main>
```

```js
add.addEventListener('click', async () => {
  const cart = await addToCart('enamel-mug');
  cartCount.textContent = cart.count;
});
```

サーバーが応答すれば数字は正しく変わります。しかし `span` はライブリージョンではなく、フォーカスのあるボタンも何も変わらないため、待っている間もそのあとも、スクリーンリーダーには読み上げるものがありません。最初の追加の処理中にもう一度押すと、マグがもう1個入ります。追加に失敗すると、何も変わりません。

## 修正後：ボタンのそばの確認の行

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">カート</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>ホーローマグ</h1>
  <p class="price">$18</p>
  <button type="button" id="add">カートに入れる</button>
  <p id="add-status" role="status"></p>
</main>
```

```js
const items = n => `${n}点`;
let adding = false;

add.addEventListener('click', async () => {
  // 処理中の押下は無視する。フォーカスのあるボタンは無効にしない
  if (adding) return;
  adding = true;
  // ショップの応答が遅いときだけ、追加中であることを伝える
  const slow = setTimeout(() => (addStatus.textContent = 'ホーローマグを追加しています…'), 500);
  try {
    const cart = await addToCart('enamel-mug');
    cartCount.textContent = cart.count;
    addStatus.textContent = `ホーローマグを追加しました。カートの商品は${items(cart.count)}です。`;
  } catch {
    addStatus.textContent = 'マグを追加できませんでした。もう一度お試しください。';
  } finally {
    clearTimeout(slow);
    adding = false;
  }
});
```

`role="status"` の `p` は空の状態で最初からHTMLにあるため、文言が変わる前から、ブラウザーはこの領域の変化を見張っています。スクリーンリーダーは読み上げ中の内容を終えてから「ホーローマグを追加しました。カートの商品は1点です」と読みます。ショップの応答に0.5秒以上かかるときは、その前に「ホーローマグを追加しています…」と読まれるため、待っている間も無音になりません。すぐに応答があれば省かれるので、二度続けて聞かされることもありません。フォーカスはボタンに残り、ボタンも有効なままです。待っている間に押しても無視され、マグが2個入ることはありません。押すたびに文中の件数が変わるので、2回目も読み上げられます。この行はボタンのすぐ下にあり、画面拡大ソフトの利用者がもともと見ている場所です。ヘッダーが見えなくなるまでスクロールしていても、画面が見える人の目に入ります。行はページが変わるまで残しておけます。追加に失敗したときも、黙らずにそのことを言葉で伝えます。

## 修正後：見た目を変えない、隠したステータス領域

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">カート</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>ホーローマグ</h1>
  <p class="price">$18</p>
  <button type="button" id="add">カートに入れる</button>
</main>
<!-- ページのレイアウトに一度だけ置く。描き直されるコンポーネントの外に -->
<div id="cart-status" role="status" class="visually-hidden"></div>
```

```js
const items = n => `${n}点`;
let adding = false;

add.addEventListener('click', async () => {
  // 処理中の押下は無視する。フォーカスのあるボタンは無効にしない
  if (adding) return;
  adding = true;
  // ショップの応答が遅いときだけ、追加中であることを伝える
  const slow = setTimeout(() => (cartStatus.textContent = 'ホーローマグを追加しています…'), 500);
  try {
    const cart = await addToCart('enamel-mug');
    cartCount.textContent = cart.count;
    cartStatus.textContent = `ホーローマグを追加しました。カートの商品は${items(cart.count)}です。`;
  } catch {
    cartStatus.textContent = 'マグを追加できませんでした。もう一度お試しください。';
  } finally {
    clearTimeout(slow);
    adding = false;
  }
});
```

ページの見た目はまったく変わらず、画面が見える人に見えるのはこれまでどおりバッジだけです。空の領域がもともとレイアウトにあるので、そこへの書き込みはブラウザーが支援技術に知らせる変化になり、スクリーンリーダーは、応答が遅いときの「追加しています」も含め、上の修正と同じ文を読みます。一つの領域で、商品一覧のクイック追加ボタンも含め、サイト中の「カートに入れる」をまかなえます。その場合は、それぞれのメッセージに商品名を入れてください。領域は `display: none` にしないでください。視覚的に隠すクラスなら、アクセシビリティツリーに残ります。

## 修正後：最小の変更として、カートの件数をステータス領域に

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span role="status" aria-atomic="true">
      <span class="visually-hidden">カート、商品数：</span>
      <span id="cart-count" class="badge">0</span>
    </span>
  </a>
</header>
<main>
  <h1>ホーローマグ</h1>
  <p class="price">$18</p>
  <button type="button" id="add">カートに入れる</button>
</main>
```

```js
add.addEventListener('click', async () => {
  const cart = await addToCart('enamel-mug');
  cartCount.textContent = cart.count;
});
```

W3Cの例にならった方法です。バッジと隠した言葉を `role="status"` の領域で包み、スクリプトは変えません。数字が変わると、領域全体が「カート、商品数：1」と読まれます。`role="status"` はもともと `aria-atomic="true"` を含みますが、W3CのテクニックARIA22によると、この既定値を適用せず新しい数字だけを読む環境もあるため、属性を明示しています。隠した言葉が数字に意味を与え、カートのリンクの名前にもなります。ただし、このコードの外の条件が保たれている間しか働きません。まず、領域がどの画面幅でもページにあること。スマートフォン用に別のヘッダーを出し、パソコン用のヘッダーを `display: none` で隠すサイトでは、領域も一緒に隠れます。隠れた領域の変化は読み上げられません。次に、ページが届いた時点でHTMLに件数が入っていること。読み込み後に件数を取得するショップでは、ページを開くたびに領域が変わり、毎回カートの件数が読み上げられるおそれがあります。そして、伝わるのは新しい数字だけです。何点入っているかは分かっても、どの商品が入ったかは分かりません。ショップが応答するまでの間は何も読まれず、その間に押すとマグがもう1個入ります。追加に失敗したときは何も変わらないため、やはり何も読まれません。

## どの方法を選ぶか

三つとも、フォーカスを動かさずに結果を伝えます。ボタンのそばの行を使ってください。コードは隠した領域と同じで、劣る点がありません。メッセージが利用者の見ている場所にも表示され、追加に時間がかかっていることや失敗したことも伝えられます。隠した領域が合うのは、変更できないテーマを使っている場合など、デザインをそのまま保つ必要があるときです。サイト中の「カートに入れる」が一度に直ります。件数をステータス領域にする方法は、包む要素を一つ足すだけでスクリプトも変えない最小の変更ですが、それだけでは足りません。ヘッダーがどの画面幅でも同じで、件数がHTMLに入っている場合に限って使い、失敗には別にメッセージを用意してください。

## 実装の判断

**アイコンが変わるだけのほかの操作。** 操作の結果が絵だけで示される場面では、同じ失敗が起き、同じ方法で直せます。

- リンクのコピー：2秒間チェックマークに変わるアイコンは聞こえません。「リンクをコピーしました」を確認の行か領域に書き込み、ブラウザーがクリップボードへの書き込みを拒んだときもそう伝えます。
- お気に入りへの保存：塗りつぶされるハートはトグルボタンで、その状態はボタン自体に `aria-pressed` で持たせます。フォーカスのあるボタンの状態は読み上げられます。[押された状態が伝わらないトグルボタン](/ja/learn/toggle-buttons)を参照してください。ステータスメッセージが要るのは、「保存しました。リストを残すにはログインしてください」のように、ボタンでは伝えられないことがある場合だけです。
- 商品一覧のクイック追加：どのカードのボタンも「カートに入れる」なので、メッセージには商品名を入れます。

**追加している間。** ボタンを押してからサーバーが応答するまでの時間も、確認の一部です。確認の行と隠した領域では、[読み上げられない読み込み中の表示](/ja/learn/loading-states)と同じように扱います。すぐに応答があれば結果だけを伝え、遅いときはタイマーで先に「追加しています…」と伝え、応答が来たらタイマーを止めます。ボタンは有効なままにして、処理中の押下は無視します。フォーカスのあるボタンを `disabled` にすると見た目は整いますが、ブラウザーがフォーカスをボタンから外すことがあります。Chromeではフォーカスがページ自体に落ち、ボタンが有効に戻っても戻らないため、スクリーンリーダーの利用者は買い物の途中で現在位置を失います。ボタンにスピナーを描くのは、名前が変わらない限りかまいません。言葉はステータス領域に任せます。無効なコントロール全般については[見つからず理由も分からない無効なボタン](/ja/learn/disabled-controls)を参照してください。

**領域を先に用意します。** コンポーネントベースのフレームワークでは、領域をレイアウトか商品のマークアップに置きます。メッセージと一緒に現れる `{#if}` の中や、追加したときに表示されるミニカートの中には置きません。メッセージと一緒に現れる領域は、たいてい読み上げられません。この失敗は[画面に出ても読み上げられないステータスメッセージ](/ja/learn/status-messages)で詳しく扱っています。

**カートのパネルは別のデザインです。** 追加するとミニカートのパネルを開くショップもあります。パネルの中へフォーカスを移してダイアログとして動くなら、ステータスメッセージではなくコンテキストの変化にあたり、何が起きたかはパネルの見出しが伝えます。[背後にフォーカスが残るモーダル](/ja/learn/modal-focus-management)に従ってください。フォーカスを移さずにスライドして出るだけのパネルは、バッジと同じく何も読まれないため、ここで紹介した修正のどれかが必要です。

**読み上げの割り込み。** `role="status"` は、スクリーンリーダーが読み終えるのを待ってから読まれるため、確認の知らせに合っています。カートへの追加に `role="alert"` や `aria-live="assertive"` を使わないでください。利用者が聞いていた内容を遮ります。

**同じ文言が続く場合。** 失敗が2回続くと同じ文を書き込むことになり、領域の文言が変わらないと何も読まないスクリーンリーダーもあります。[件数が読み上げられない検索・絞り込み結果](/ja/learn/search-results)と同じように、先に領域を空にし、少し間を置いてから新しい文言を書き込みます。

**修正にならないもの。** ボタンの文言を2秒間「追加しました」に変えても、フォーカスのあるボタンの変化を読み上げるかどうかはスクリーンリーダーによって異なり、カートの状態も伝わりません。カートのリンクやメッセージへフォーカスを移せば読まれますが、利用者を商品から引き離し、4.1.3が求める方法でもありません。件数に合わせて変わるカートのリンクの `aria-label` は、リンクにフォーカスがないため読み上げられません。

## 修正の確認

1. 押す前にDOMを確認し、ステータスの行または領域があり、`role="status"` を持ち、`display: none` や `aria-hidden` で隠れておらず、描き直される要素の中にないことを確かめる。件数を領域にした場合は、サーバーが返したHTMLにすでに数字が入っていることも確かめる。
2. スクリーンリーダーを有効にして「カートに入れる」を押す。読み上げ中の内容のあとに結果が一度読まれ、フォーカスはボタンに残る。
3. 開発者ツールで通信を遅くして（Slow 4Gなどのプリセット）、素早く2回押す。フォーカスはボタンに残る。確認の行か隠した領域なら、結果の前に「追加しています」が一度読まれ、2回目の押下では追加されない。次にオフラインにしてもう一度押し、失敗が言葉で読み上げられることを確かめる。
4. ページを再読み込みし、別のページへ移る。読み込みの際にカートについて何も読み上げられない。次にウィンドウをスマートフォンの幅まで狭めて追加し、それでも読み上げられることを確かめる。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。ヘッダー、商品ページのテンプレート、カートのスクリプトを変更した際は、再度確認します。

## 制限

ライブリージョンの動きは、スクリーンリーダーやブラウザーによって異なります。特に、リンクの中の領域、移動した領域、ダイアログ内の領域では差が出ます。このパターンは、一つの商品ページの一つのボタンを扱っています。カートのボタン自体の名前は[名前がないアイコンだけのボタン](/ja/learn/icon-buttons)で、「カートに入れる」の隣の数量は[ボタンに名前がなく、数を入力できない数量ステッパー](/ja/learn/quantity-steppers)で扱っています。ニュースレターの登録のように、送信して結果を表示するフォームは別のトピックです。パネル、クイック追加ボタン、スマートフォン用のヘッダーを含めた実際のカートを、利用者が使うスクリーンリーダーで確認してください。

---

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