# ストリーミング中に細切れで読み上げられてしまうAIチャットの返答

Source: https://easeweb.dev/learn/streamed-replies
Topics: ライブ更新 > AIの回答が少しずつ表示されても、細切れでなく、まとめて読み上げられる (WCAG 4.1.3)
The fix: 会話のrole="log"にaria-live="off"を付けて、ストリーミング中の文字をライブリージョンの外に出し、その横に、最初から空のrole="status"の領域を置きます。返答がそろったら、その領域に一度だけ書き込みます。返答の全文を書き込んでひとまとまりで読ませるか、アシスタントが回答したことだけを短く伝え、返答は利用者が自分のキーで読めるようにします。
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA23 https://www.w3.org/TR/wai-aria-1.2/#aria-live https://www.w3.org/TR/wai-aria-1.2/#aria-busy https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

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

## 場面

アウトドア用品店のFernhillは、ヘルプページにAIアシスタントを置いています。「アシスタントに質問」という見出し、あいさつから始まる会話、質問欄と「送信」ボタンがあります。会話は `role="log"` で作られており、新しいメッセージは欄を離れなくても読み上げられます。チャットとしては自然な作りです。スクリーンリーダーの利用者が「使ったテントも返品できますか？」と入力してEnterキーを押します。

AIの返答によくあるように、回答はストリーミングで届きます。数語が表示され、また数語が続き、やがて吹き出しが2つの文で埋まります。画面が見える人は、伸びていく文をそのまま読みます。スクリーンリーダーの利用者には、まず自分の質問が読み返され、次に「アシスタント：」、それから「はい、使用済みでも」「30日以内」「なら返品」「できます」と、届いた断片が一つずつ別々のメッセージとして聞こえます。尋ねた回答は切れ端の連続として届き、ストリーミングが止まっても全部聞けたのかが分かりません。結局、欄を離れて返答を最初から読み直します。

## 確認できる問題

`role="log"` はライブリージョンです。追加された内容は、届いた順に、控えめに読み上げられます。メッセージがまとまって届くチャットなら、それで合っています。この例では、スクリプトがストリーミングの断片を届くたびに返答へ書き足し、その一つひとつが追加になるため、断片ごとにメッセージになります。こうした変化の連続をどう読むかは、スクリーンリーダーとブラウザーによって異なります。あるオープンソースのAIチャットには、NVDAとFirefoxで確かめた不具合として、DOMに追加されるトークンや断片の一つひとつが新しい読み上げになる、という報告が出ています。

達成基準4.1.3は、ステータスメッセージを、フォーカスを受け取らずに支援技術が利用者へ伝えられるようにすることを求めています。その解説書によれば、目的は、変化を「作業を不必要に妨げることなく」伝えることです。同じ解説書は、これに近い例にも触れています。メッセージの一部だけが変化として扱われると、利用者にはその部分しか聞こえず、文脈を伝えるには足りないことがある、というものです。この例でも返答そのものはたどれますが、ライブリージョンが伝えているのはメッセージではなく、断片です。

## 利用者への影響

ストリーミングは、文字を目で追う人に向けた作りです。アシスタントが動いていることが分かり、すぐに読み始められます。返答が長いほど細かく分かれて聞こえ、箇条書きや手順を含む返答は、何十もの切れ端として読まれることがあります。

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

スキャンには、あいさつの入った名前付きの `role="log"`、ラベルのある欄、ボタンが見え、問題は見つかりません。W3Cがチャットの履歴に示しているパターンそのものだからです。問題は返答がストリーミングで届く間にしか起きず、それにはアシスタントに質問する必要がありますが、監査は公開中のサイトでそれをしません。EaseWebの監査は、AIアシスタントと名の付いた部分で、入力欄の横にライブリージョンの会話があると、人が実際に質問して確かめる項目として挙げます。ただし、返答がストリーミングで届くかどうかは、ページからは分かりません。チャットをアクセシブルにするよう頼まれたAIのコーディングアシスタントが、会話に `role="log"` や `aria-live="polite"` を付けることもあります。それがまさにこのパターンです。AIチャット向けのコンポーネントライブラリーにも同じものがあります。たとえばVercelのAI Elementsの会話コンポーネントは、2026年10月の時点で、各返答がストリーミングで書き込まれる `role="log"` です。返答が届く間にスクリーンリーダーで聞いて、初めて分かります。

## 修正前：断片を一つずつ書き足すライブリージョンのログ

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">アシスタントに質問</h2>
  <div class="messages" role="log" aria-label="会話">
    <p class="message from-assistant"><span class="visually-hidden">アシスタント：</span>こんにちは。ご注文、サイズ、返品についてお尋ねください。</p>
  </div>
  <form class="ask-form">
    <label for="ask-input">質問</label>
    <input id="ask-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'アシスタント：' : 'あなた：';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() は、モデルのAPIが送るとおり、返答を数語ずつ返す
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
});
```

会話は名前の付いたログで、どのメッセージも書いた人を伝え、フォーカスは欄に残ります。問題はストリーミングにあります。

- 断片はライブリージョンのログの中で返答に書き足されるため、「はい、使用済みでも」「30日以内」「なら返品」と、一つずつ別のメッセージとして読み上げられます。
- 返答がそろったときには何も伝えられないため、利用者は聞いた内容から、回答が終わったのか、まだ続くのかを判断できません。

## 修正後：そろった返答を一度だけ読む

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">アシスタントに質問</h2>
  <div class="messages" role="log" aria-label="会話" aria-live="off">
    <p class="message from-assistant"><span class="visually-hidden">アシスタント：</span>こんにちは。ご注文、サイズ、返品についてお尋ねください。</p>
  </div>
  <p id="reply-status" class="visually-hidden" role="status"></p>
  <form class="ask-form">
    <label for="ask-input">質問</label>
    <input id="ask-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const replyStatus = document.getElementById('reply-status');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'アシスタント：' : 'あなた：';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  replyStatus.textContent = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() は、モデルのAPIが送るとおり、返答を数語ずつ返す
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
  // そろったら、書いた人と一緒に返答の全文を一度だけ読ませる
  replyStatus.textContent = reply.textContent;
});
```

1. 会話に `aria-live="off"` を付けます。名前の付いたログのままなので、利用者は見つけてさかのぼって読めますが、フォーカスがほかにある間は、追加された内容は読み上げられません。ストリーミングは静かになります。
2. 視覚的に隠した空の `p` に `role="status"` を付け、会話の外、そのすぐ横に置きます。最初の質問より前からページにあるため、書き込むと、控えめに読み上げられる変化になります。
3. 最後の断片が届いたら、返答の全文をそこに書き込みます。返答の文字は隠した「アシスタント：」から始まるため、スクリーンリーダーは読み上げ中の内容を終えてから、「アシスタント：はい、使用済みでも30日以内なら返品できます…」とひとまとまりで一度読みます。
4. 質問を送るときに領域を空にします。前の返答とたまたま一字一句同じ返答でも、変化として読まれます。

これで修正前の問題は直り、4.1.3を満たします。返答は、フォーカスを動かさずに、ひとつのメッセージとして一度伝えられます。ただし、返答が長いほど、何か聞こえるまでの待ち時間が延び、一度に聞く量も増えます。

## 修正後：返答がそろったことを短く伝える

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">アシスタントに質問</h2>
  <div class="messages" role="log" aria-label="会話" aria-live="off">
    <p class="message from-assistant"><span class="visually-hidden">アシスタント：</span>こんにちは。ご注文、サイズ、返品についてお尋ねください。</p>
  </div>
  <p id="reply-status" class="visually-hidden" role="status"></p>
  <form class="ask-form">
    <label for="ask-input">質問</label>
    <input id="ask-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const replyStatus = document.getElementById('reply-status');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'アシスタント：' : 'あなた：';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  replyStatus.textContent = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() は、モデルのAPIが送るとおり、返答を数語ずつ返す
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
  // そろったら一度だけそう伝え、返答はその場で読んでもらう
  replyStatus.textContent = 'アシスタントが回答しました。';
});
```

1. 一つ目の方法と同じく、会話に `aria-live="off"` を付けます。ストリーミングは静かになり、ログはさかのぼって読めます。
2. 同じく、視覚的に隠した空の `role="status"` の領域を横に置きます。
3. 最後の断片が届いたら、「アシスタントが回答しました。」という短いメッセージを書き込みます。利用者は、ページのほかの文章と同じように、自分の読み上げキーで、1行ずつ、箇条書きなら1項目ずつ、止めたり戻ったりしながら会話の中の返答を読みます。返答は会話の最後、フォームの直前にあるメッセージなので、欄からさかのぼると、欄のラベルと短いメッセージのすぐ次にたどり着きます。
4. 質問を送るときに領域を空にします。次の返答でも、同じメッセージがもう一度読まれます。

これで修正前の問題は直り、4.1.3を満たします。返答の終わりはフォーカスを動かさずに一度伝えられ、返答そのものは、利用者が好きなときにひとまとまりで読めます。

## どの方法を選ぶか

どちらもストリーミング中は何も読まず、そろったときに一つだけ伝えます。違うのは、その一つが何かです。返答の全文を読む方法は、ショップのヘルプのように、答えが1、2文で済むアシスタントに合っています。ほかのチャットでメッセージが届いたときと同じく、何も操作しなくても答えが聞こえます。ただし、返答は区切りのないひと続きの文として読まれるため、箇条書き、表、コードは形を失い、数段落の返答は、読み直す以外に戻る手段のないまま長く聞き続けることになります。短いメッセージで伝える方法は、答えが長いアシスタントや、構造のある答えを返すアシスタントに合っています。利用者は答えがそろったことを聞き、見出し、箇条書き、コードを保ったまま、ほかの文章と同じように自分のペースで読みます。普段の答えの長さと形で選んでください。

## 実装の判断

**修正にならないもの：`aria-busy`。** ARIAは、こうした場面での使い方として `aria-busy="true"` を挙げています。返答のストリーミング中はログに付け、そろったら `false` に戻すと、支援技術は変化をまとめて一度に読んでもよい、とされています。仕様の言葉は「してもよい」なので、スクリーンリーダーが変化を届いたそばから読んでも仕様には反しません。busy にしたログでも細切れに読まれることがあります。どちらの修正と一緒に使っても害はありませんが、それだけでは修正になりません。

**修正にならないもの：返答の吹き出しを atomic にする。** 吹き出しに `aria-atomic="true"` を付けたり、断片が届くたびに返答の全文を書き直したりすると、変化のたびに返答が最初の語から読み直されます。返答が伸びるにつれ、利用者は冒頭の言葉を何度も聞くことになります。

**最初の語が届くまでの待ち時間。** 多くのアシスタントは、返答が始まる前に「考え中…」の表示を出します。伝える必要があるほど待ち時間が長いなら、ステータス領域に一度だけ書き込みます。繰り返し出る表示や、文言が毎秒変わる表示を、ライブリージョンの中に置かないでください。待ち時間そのものについては、[読み上げられない読み込み中の表示](/ja/learn/loading-states)で扱っています。

**止めた返答と失敗した返答。** 「停止」ボタンはストリーミングを途中で終わらせます。ステータス領域に「停止しました。」か、途中までの返答を書き込み、回答が完全ではないことを伝えます。ストリーミングが失敗したときは、何が失敗し、どうすればよいかを同じ領域に書き込みます（「アシスタントが回答できませんでした。もう一度お試しください。」）。ストリーミングがどう終わっても書き込まれるよう、`finally` ブロックの中か `try` のあとに書きます。

**返答が複数あるとき。** 2、3通のメッセージで答えるアシスタントや、ツールを動かして各段階を報告するアシスタントでも、伝えるのはそろったメッセージ1通ごとに一つです。ツールの段階ごとに読み上げたり、ステータス領域にメッセージを積み重ねたりしないでください。領域には最新の一つだけを入れます。

**assertive ではなく polite に。** 返答は、利用者が読んでいる内容に割り込むほど急ぎのものではありません。`role="alert"` や `aria-live="assertive"` は使わないでください。

**返答へフォーカスを移さない。** 移せば返答は読まれますが、利用者が次の質問を入力している欄からカーソルが抜けてしまいます。

**まったく読み上げないチャット。** 会話がどのライブリージョンにもなく、返答がまったく読まれないチャットは、別の問題です。[メッセージを読み上げないチャットウィジェット](/ja/learn/chat-widgets)を参照してください。そこでの修正は、まとまって届く返答のために `role="log"` を使います。返答がストリーミングで届くなら、このガイドの、ログを静かにする方法とステータス領域を加えてください。

## 修正の確認

1. 何も送る前にDOMを確認し、返答がストリーミングで書き込まれる会話がライブリージョンになっていないこと（`role="log"` なら `aria-live="off"` が付いていること）、返答の吹き出しに `aria-live` や `aria-atomic` がないこと、会話の外に空の `role="status"` の領域があり、`display: none` や `aria-hidden` で隠れていないことを確かめる。
2. スクリーンリーダーを有効にしてマウスから手を離し、返答が数文になる質問をする。ストリーミング中は何も読まれず、そろうと返答の全文か短いメッセージが一度読まれ、フォーカスは欄に残っている。
3. 同じ質問をもう一度する。返答かメッセージが、もう一度読まれる。返答を途中で止め、別の返答の途中でネットワークを切る。それぞれ、止まったこと、失敗したことが一度伝えられる。
4. 欄から、スクリーンリーダーの読み上げキーで会話をさかのぼる。そろった返答があり、書いた人と一緒に読まれる。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。チャットのコンポーネント、ストリーミングの処理、コンポーネントライブラリーを変更した際は、再度確認します。

## 制限

ライブリージョンの変化が続いたとき、どんな間隔で読むか、そもそも読み上げるのか、後回しにするのか、飛ばすのかは、スクリーンリーダー、ブラウザー、そのリリースによって異なります。利用者が使うものでストリーミングを確かめてください。細切れに読まれる返答を、伝えられているのがメッセージではないとして4.1.3の不適合とする監査人もいれば、ログが変化を一つずつ伝えてはいるとして使い勝手の指摘にとどめる監査人もいます。どちらにしても、利用者は回答を追えません。このガイドは、一つのページの一つのアシスタントを扱っています。ベンダーのフレームの中に描かれるアシスタントは、ベンダーの設定からしか変えられません。ベンダーに尋ねることは、[メッセージを読み上げないチャットウィジェット](/ja/learn/chat-widgets)にまとめています。自動で変わるスコア、ティッカー、タイマーは別の問題で、[スクリーンリーダーが読み上げ続けるカウントダウンタイマー](/ja/learn/countdown-timers)で扱っています。

## スクリーンリーダーでの動き（EaseWebが実機で確認）

`role="status"`: polite のライブリージョンです。変化は、スクリーンリーダーが読み終えるまで待ちます。

| スクリーンリーダー | ブラウザーとOS | 下矢印キー | 連続読み上げ |
| --- | --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 | 未確認 |
| TalkBack | Chrome, Android | 未確認 | 未確認 |

`role="log"`: チャットのように、項目が順に追加される polite のライブリージョンです。追加された内容が読み上げられ、すでにある内容は繰り返されません。

| スクリーンリーダー | ブラウザーとOS | 下矢印キー | 連続読み上げ | ストリーミング |
| --- | --- | --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 | 未確認 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 | 未確認 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 | 未確認 | 未確認 |
| TalkBack | Chrome, Android | 未確認 | 未確認 | 未確認 |

`role="log" aria-live="off"`: 領域の読み上げをオフにします。ARIAは、フォーカスが中にあるとき以外は、変化を伝えないよう求めています。

| スクリーンリーダー | ブラウザーとOS | ストリーミング |
| --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 |
| TalkBack | Chrome, Android | 未確認 |

https://easeweb.dev/ja/learn/screen-reader-support

---

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