生成 AI の登場以降、テキスト入力で AI と対話するチャット UI は身近な存在になりました。従来のチャットではメッセージが完成した状態で追加されることが一般的でしたが、LLM(大規模言語モデル)の回答は数秒から数十秒かけて少しずつ画面に表示されます。
画面を見ているユーザーは文字が増えていく様子から、回答の生成が始まったことや、まだ生成中であることに気づけます。一方、スクリーンリーダーは DOM が更新されたという理由だけで、常にその内容を読み上げるわけではありません。何も対応しなければ回答が始まったことに気づくことができず、完了したことも伝わりません。
スクリーンリーダーなどの支援技術に動的にコンテンツが追加されたことを通知するには、ARIA ライブリージョンを使用するのが一般的です。しかし、実装の方法を誤ると、生成途中の文章が細切れに何度も読み上げられてしまうという問題が発生する恐れがあります。下記の動画では新しいチャンクが追加されるたびにスクリーンリーダーが冒頭の数文字を繰り返し、「ウェウェウェウェ...」と読み上げが止まらなくなっています。
この記事ではストリーミングされる回答をスクリーンリーダーへどのように通知するかという観点で既存のサービスやライブラリの実装を調査したうえで、実際にプラクティスを踏まえたサンプルを実装します。
ここでのプラクティスはストリーミングされる回答本文そのものは自動通知の対象にせず、「回答を生成しています」「回答が完了しました」といった短い状態メッセージだけを、本文とは別の要素から通知するというのが基本的な方針です。回答本文と状態通知を分離することで、生成途中のテキストが細切れに読み上げられることを防ぎながら、回答が始まったことと終わったことを伝えられます。
なお、この記事に記載しているスクリーンリーダーの読み上げ結果は、以下の環境で確認したものです。
- macOS 26.5.1
- Google Chrome 151.0.7922.77
- macOS に付属する VoiceOver
ARIA ライブリージョンとは
チャット UI のように、ユーザーが操作していない間に画面の内容が変化する場合、内容が変化したことを何らかの方法でユーザーに伝える必要があります。スクリーンリーダーへ動的な変更を通知する仕組みとして、ARIA ライブリージョンがあります。まずは ARIA ライブリージョンの基本的な使い方を確認しましょう。
まずは基本の aria-live 属性です。aria-live 属性を指定した要素の内容が変化すると、ブラウザはアクセシビリティ API を通じて支援技術へ変更を伝えます。
<!-- JavaScript で動的にコンテンツを更新される想定 -->
<div aria-live="polite"></div>aria-live では、主に以下の値を使用します。
polite:ユーザーが現在聞いている読み上げを遮らないタイミングで通知するassertive:現在の読み上げへ割り込んで通知するoff:要素にフォーカスがある場合などを除き、通常は変更を自動通知しない
頻繁な割り込みは操作を妨げるため、通常の状態通知には polite を使用します。assertive は即座に伝える必要がある重要な警告など、限られた場面で使用するべきです。
ARIA の一部のロールには、暗黙の aria-live が定義されています。たとえば status ロール は aria-live="polite" と aria-atomic="true"、log ロール は aria-live="polite" を暗黙に持ちます。
aria-atomic="true" は、ライブリージョンの一部だけが変更された場合にも、その領域全体を 1 つの単位として通知することを示します。たとえば検索結果の件数が 1 件から 2 件に変化した場合、aria-atomic="true" が指定されていれば「検索結果は 2 件です」と全体を読み上げます。aria-atomic="true" を設定しない場合、単に「2」とだけ読み上げられ、ユーザーは何が 2 なのかを理解できません。
<div role="status" aria-atomic="true">
検索結果は <span id="result-count">1</span> 件です
</div>
<button id="search">検索</button>
<script>
const count = document.querySelector("#result-count");
const search = document.querySelector("#search");
search.addEventListener("click", () => {
count.textContent = "2";
});
</script>role="log" はチャット履歴のためのロール
log ロールは、古い情報が消えず、新しい情報が意味のある順序で末尾へ追加される領域を表します。チャット履歴、エラーログ、ゲームログなどが代表例です。
W3C の WCAG Techniques でも、role="log" を使う例としてチャットの会話履歴が示されています。つまり、チャット履歴へ role="log" を指定すること自体は標準的な方法です。
<div role="log" aria-label="チャット履歴">
<!-- 新しいメッセージが末尾へ追加される -->
</div>LLM が登場する以前のチャット UI であれば、ユーザーが送信したメッセージは、完成した状態で DOM へ追加されることが一般的でした。role="log" を指定するだけで、スクリーンリーダーは新しいメッセージが追加されたことを 1 度だけ通知できます。しかし、LLM が登場してからは、回答が少しずつ生成されるため、ユーザーが送信したメッセージに対する回答用の要素を追加した後、その内部のテキストを何度も変更します。暗黙に aria-live="polite" となる role="log" の内部で更新を繰り返すと、支援技術が生成途中のテキストまで変更のたびに通知する恐れがあります。
つまり、LLM の登場以降のチャット UI では、role="log" を指定するだけでは不十分で、ストリーミングされる回答の通知方法を慎重に設計する必要があるのです。ここからは、実際のサービスやライブラリがどのように実装しているかを確認します。
ChatGPT の HTML 構造
まずは ChatGPT が会話の生成中にどのような DOM を構築しているのか見ていきましょう。
ChatGPT では会話全体を特定の role でラップしていません。ユーザーと ChatGPT の各ターンは <section> 要素で表現され、視覚的に隠された <h4> で送信者が示されていました。画面上では吹き出しの位置でユーザーのメッセージか AI の回答か判断できますが、スクリーンリーダーは見た目の違いを認識できないため、視覚的に隠されたテキストで送信者が読み上げられるようにしているのだと思われます。
<section data-turn="user">
<h4 class="sr-only">あなた:</h4>
<!-- ユーザーのメッセージ -->
</section>
<section data-turn="assistant">
<h4 class="sr-only">ChatGPT:</h4>
<!-- ChatGPT の回答 -->
</section>sr-only は画面上では表示せず、スクリーンリーダーからは読み取れる状態にするための CSS クラスです。display: none や hidden を使用するとアクセシビリティツリーからも削除されるため、1px の領域に切り詰める方法などが使われます。
ChatGPT の回答本文には、aria-live や aria-busy は指定されていませんでした。つまり、動的に更新される回答本文はスクリーンリーダーへ自動通知されない構成です。回答とは別に、視覚的に隠された role="status" aria-live="polite" aria-atomic="true" の要素が配置されています。この要素は、生成中は「考え中」、生成が終わると「回答が完了しました」といったテキストへ更新され、回答の状態を通知します。
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<script>
const status = document.querySelector('[role="status"]');
// 回答の生成が始まったとき
status.textContent = "考え中";
// 回答の生成が完了したとき
status.textContent = "回答が完了しました";
</script>実際にスクリーンリーダーで確認すると、フォームをサブミットした後は「考え中」と読み上げられ、生成が完了すると「回答が完了しました」と読み上げられることが確認できます。
このように ChatGPT では本文自体を通知の対象にせずに、短いテキストで状態を通知する構成になっていました。スクリーンリーダーのユーザーは、回答が完了したことを知った後、任意のタイミングで回答本文を読み進められます。
Claude の HTML 構造
Claude の会話履歴は ARIA feed パターンを採用していました。feed は、ユーザーがスクロールして過去のメッセージを確認できるような、意味のある順序で追加されるコンテンツの集合を表します。
<div role="feed" aria-label="Chat messages">
<div
role="article"
aria-posinset="1"
aria-setsize="2"
aria-label="Message 1 of 2"
>
<!-- メッセージ -->
</div>
</div>各メッセージは role="article" で、aria-posinset と aria-setsize により履歴中の位置と総数を表現します。
aria-posinset:その要素が集合の中で何番目にあるかを示す。上記の例では 1 番目のメッセージaria-setsize:集合全体にいくつの要素があるかを示す。上記の例では全部で 2 件のメッセージ
DOM 上に存在する要素の数から位置や総数を判断できない場合に、これらの属性で明示します。チャット履歴のように古いメッセージを遅延読み込みする UI では、DOM に一部のメッセージしか存在しないことがあるため、「全 20 件中の 15 件目」のような情報を支援技術へ正しく伝えられます。
生成中の回答には aria-label="Currently streaming message" が指定され、生成完了後は aria-label="Message 2 of 2" のような名前に変化していました。
Claude も ChatGPT と同様に、各メッセージの先頭へ sr-only の見出しを配置して送信者を明示しています。ユーザーのメッセージには「You said」、Claude の回答には「Claude responded」というテキストが使われていました。
<div role="article" aria-label="Message 1 of 2">
<h2 class="sr-only">You said: 今日は何の日?</h2>
<!-- ユーザーのメッセージ -->
</div>
<div role="article" aria-label="Message 2 of 2">
<h2 class="sr-only">Claude responded: 8月11日は山の日です</h2>
<!-- Claude の回答 -->
</div>画面上ではメッセージの位置や見た目から送信者を区別できますが、その視覚的な違いはスクリーンリーダーには伝わりません。見出しとして送信者を明示することで、誰の発言かを確認できるだけでなく、見出しジャンプを使ってメッセージ間を移動できます。
feed は単にスクロールできる一覧へ指定するロールではありません。Claude では「上下矢印キーでメッセージ間を移動できる」というスクリーンリーダー向けの説明があり、各 article もフォーカス可能です。feed を採用する場合は、このようなフォーカス移動やコンテンツ読み込みの規約まで実装する必要があります。
Claude もストリーミングされる回答本文をライブリージョンにはしていません。別の role="status" から、生成開始時に「Claude is responding」、完了時に「Claude finished the response」と通知します。フォーカスは回答完了後も入力欄に残っていました。
ChatGPT と Claude では履歴の構造が異なりますが、以下の 3 点は共通しています。
- ストリーミングされる回答本文を、そのままライブリージョンとして通知しない
- 回答本文とは別の
role="status"から、短い状態を通知する sr-onlyの見出しでユーザーと AI の送信者を区別できるようにする
チャット UI ライブラリの実装
続いて、チャット UI を構築するためのフロントエンドライブラリがどのような実装になっているのか調査しました。確認したライブラリは以下の 4 つです。
- assistant-ui
- Vercel AI Elements
- CopilotKit
- shadcn/ui Chat Components
実装を確認したうえで、それぞれ VoiceOver と Chrome でスクリーンリーダーの読み上げを検証しました。
assistant-ui
assistant-ui の標準 Thread コンポーネントは、履歴と各メッセージを基本的に <div> としてレンダリングします。標準テンプレートの回答本文には role="log" や aria-live は指定されていません。
生成中のドットインジケーターには aria-label="Assistant is working" が設定されていますが、この要素自体はライブリージョンではありません。一方、別途提供されているTypingIndicator コンポーネントは role="status" と aria-label="Assistant is typing" を使用しています。
つまり、回答が生成中であることはスクリーンリーダーへ通知されますが、回答が完了したことを通知する手段はありません。そのため、スクリーンリーダーのユーザーは回答の生成が終わったことに気づけないという問題があります。
Vercel AI Elements
Vercel AI Elements のConversation コンポーネントには role="log" が指定されています。そのため、利用者が明示的に aria-live を追加しなくても、会話履歴は暗黙に polite なライブリージョンになります。
その結果、回答本文がストリーミングされると、スクリーンリーダーは生成途中の文章を細切れに読み上げる可能性があります。
CopilotKit
CopilotKit は、現行のv1 Messages コンポーネントとv2 CopilotChatMessageViewを確認しました。
どちらもメッセージ一覧は通常の <div> で構成されています。回答のストリーミングに対する role="log"、aria-live、role="status" は確認できませんでした。
スクリーンリーダーへ回答の開始・完了を通知する仕組みは、アプリケーション側で追加する必要があります。
shadcn/ui Chat Components
shadcn/ui は、2026 年 6 月に公式の Chat Componentsを公開しました。
中心となるMessageScrollerContentは、デフォルトで role="log" と aria-relevant="additions" を設定します。aria-relevant は、ライブリージョン内のどの種類の変更を通知対象とするかを指定する属性です。指定できる値は以下のとおりです。
additions:ライブリージョン内に要素ノードが追加された変更を対象とするtext:テキストコンテンツやテキストノードが追加された変更を対象とするremovals:テキストや要素ノードが削除された変更を対象とするall:additions removals textと同じ
aria-relevant を指定しない場合の既定値は additions text です。つまり、要素ノードの追加とテキストの変更の両方が通知の対象になります。
shadcn/ui があえて additions だけを指定しているのは、既定値から text を除外するためです。これにより、新しいメッセージ要素が追加されたときは通知の対象になりますが、既存の要素の中身をストリーミングで書き換える変更は対象から外れます。まさにストリーミング回答が細切れに読み上げられる問題を避けるための指定です。
ただし aria-relevant の解釈は支援技術によって異なります。Accessibility Support の検証結果によると、既定値である additions text はいずれのスクリーンリーダーでもサポートされている一方、additions 単体の指定は NVDA ではサポートされておらず、VoiceOver と JAWS でも部分的なサポートにとどまります。
またストリーミングのサンプルでは、回答中に MessageScrollerContent へ aria-busy="true" を指定しています。
aria-busy は、要素に対する更新がまだ完了していないことを示します。仕様では、支援技術は aria-busy="true" の間の変更を保留し、false になった後にまとめて処理できます。ただし、WAI-ARIA の仕様では支援技術が更新の通知を保留してよいという “MAY” の要件であり、スクリーンリーダーとブラウザの組み合わせで同じ読み上げになるとは限りません。
VoiceOver と Chrome で検証したところ、回答の生成中は読み上げが保留され、生成が完了した後にはじめてまとめて読み上げられることを確認できました。細切れに読み上げるという問題自体は回避できていますが、生成が完了した後に長文をまとめて読み上げられるため、ユーザーの操作を妨げる可能性があります。あまりに過剰な通知はユーザーにとって好ましくありません。Understanding Success Criterion 4.1.3: Status Messages も合わせて参照してください。
ライブラリ調査のまとめ
4 つのライブラリの調査結果を表にまとめると以下のようになります。
| ライブラリ | 履歴のロール | 生成中の通知 | 完了の通知 | 追加で必要な対応 |
|---|---|---|---|---|
| assistant-ui | なし(<div>) |
role="status"(TypingIndicator) |
なし | 完了を通知する仕組みの追加 |
| Vercel AI Elements | role="log" |
回答本文が暗黙に通知される | なし | 履歴の aria-live="off" 化と状態通知の分離 |
| CopilotKit | なし(<div>) |
なし | なし | 開始・完了の通知をすべて追加 |
| shadcn/ui Chat Components | role="log" + aria-relevant="additions" |
aria-busy="true" で保留 |
完了後に本文をまとめて読み上げ | 状態通知への置き換えと支援技術ごとの検証 |
このように、ライブラリごとに対応はあまり統一されていません。共通して言えるのは、どのライブラリを選んでも、回答の開始と完了を短いテキストで通知する仕組みはアプリケーション側で用意する必要があるということです。実際にスクリーンリーダーで検証することも重要です。支援技術ごとに挙動が異なる場合があるためです。
トークンごとに読み上げられた実際の Issue
ストリーミング回答をライブリージョン内で更新した結果、実際に問題となった事例があります。OpenClaw では、role="log" と aria-live="polite" を持つチャット履歴の中で回答をストリーミングしていました。その結果、NVDA と Firefox の組み合わせで、回答がトークンやチャンクごとに読み上げられる Issueが報告されています。読み上げを停止しても次の更新で再開され、回答中は UI を使用することが難しい状態でした。
この Issue では、生成中だけ aria-live="off" へ切り替える方法や、aria-busy で更新を保留する方法が提案されました。しかし、実際の支援技術による検証が不足していたことや、スクリーンリーダー間の互換性が不明なことから、提案された PR はそのままでは採用されませんでした。
現在の OpenClaw は、履歴を role="log" aria-live="off" とし、別の視覚的に隠された role="status" aria-live="polite" aria-atomic="true" から完成した回答について通知する構成です。この構成では、回答本文自体はスクリーンリーダーへ通知されず、「回答が完了しました」のような短いテキストだけが通知されます。
この事例からも、ストリーミングされる本文と、自動的に読み上げる内容を分けて設計することの重要性が分かります。
アクセシビリティを考慮したチャット UI の実装例
ここまで調査した内容を踏まえて、アクセシビリティ上、チャット UI に必要な要件を整理しましょう。主に以下の 3 点が重要です。
- 回答本文はスクリーンリーダーへ自動通知されない
- 回答の開始・完了・停止・エラーはスクリーンリーダーへ通知される
- 視覚的な表現に頼らず、メッセージの送信者がスクリーンリーダーからも分かるようにする
これらの要件を満たしたサンプルのチャット UI を実装してみましょう。まず、チャット履歴のコンテナには role="log" を指定したうえで、aria-live="off" を指定します。
<div
id="messages"
role="log"
aria-live="off"
aria-label="チャット履歴"
tabindex="0"
>
<!-- ストリーミングされるメッセージ -->
</div>aria-live="off" を指定すると role="log" の暗黙のライブリージョンが無効になるため、ストリーミングされる回答本文が自動で読み上げられることはなくなります。
それなら role="log" を指定する意味はないのではという疑問が浮かぶでしょう。しかし aria-live が無効になっても、ロールとアクセシブルネームはアクセシビリティツリーに残ります。実際に Chrome のアクセシビリティツリーを確認すると、aria-live="off" を指定した状態でも「チャット履歴」という名前を持つ log として公開されていました。
つまり、自動通知を止めることと、その領域が何であるかを表すことは別の話です。role="log" は、この領域が「古い情報が消えず、新しい情報が順に追加されていく履歴である」という意味を支援技術へ伝えるために残しています。
tabindex="0" を指定しているのは、履歴の領域がスクロール可能だからです。スクロールできる領域にフォーカスを置けないと、マウスを使わないキーボードユーザーが過去のメッセージまでスクロールできません。aria-label を指定してあるため、フォーカスしたときに「チャット履歴」と読み上げられます。
ただし、これだけではスクリーンリーダーを利用するユーザーが回答の開始や完了に気づけません。
そこで、履歴とは別に空の role="status" を用意します。回答の開始・完了・停止・エラーのタイミングで、ここのテキストを更新することで、スクリーンリーダーへ通知します。role="status" を持つ要素を動的に DOM へ挿入した場合、その内容は通知されないことがあるため、最初から DOM に配置しておく必要があります。
<div
id="chat-status"
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
></div>role="status" は暗黙に aria-live="polite" と aria-atomic="true" を持つため、属性を省略しても同じ意味になりますが、ここではわかりやすさのために明示しています。
このコンテンツは画面上には表示せず、スクリーンリーダーからのみ読み上げられるようにします。先述のとおり display: none や hidden はアクセシビリティツリーからも削除されてしまうため、ここでは .sr-only クラスを定義して視覚的に非表示にします。
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}回答のライフサイクルに合わせて、status のテキストだけを更新します。
function announce(message) {
const status = document.querySelector("#chat-status");
status.textContent = "";
setTimeout(() => {
status.textContent = message;
}, 100);
}
announce("回答を生成しています");
// ストリーミング中は回答本文だけを更新する
announce("回答が完了しました");textContent をいったん空にしてから、setTimeout で少し遅らせて設定している点に注意してください。スクリーンリーダーはライブリージョンの変更を検出して読み上げるため、直前とまったく同じ文言を代入しても変更とみなされず、読み上げられないことがあります。一度空文字にしてから設定し直すことで、同じ文言を再通知する場合に、変更として検出されやすくするための実装上の工夫です。ただし、通知結果はブラウザと支援技術の組み合わせに依存します。
同じ status から、停止とエラーも polite に通知します。
announce("回答の生成を停止しました");
announce("回答を生成できませんでした。もう一度お試しください");生成エラーは通常、現在の操作へ即座に割り込む必要がある緊急事態ではないため、role="alert" や aria-live="assertive" は使用していません。
メッセージの送信者を見た目だけで表現しない
チャット UI は、ユーザーのメッセージを右側、AI の回答を左側へ配置したり、吹き出しの色を変えたりして送信者を表現します。しかし、位置や色の違いだけではスクリーンリーダーへ送信者が伝わりません。
サンプルでは、各メッセージを <article> とし、sr-only の見出しで送信者を示しています。
<article aria-labelledby="message-2-author">
<h2 id="message-2-author" class="sr-only">AI の回答</h2>
<div>回答本文</div>
</article>これにより履歴を後から読み進めたときにも、誰の発言なのかを確認できます。
送信後も入力欄にフォーカスを残す
回答が始まったときや完了したときに、回答へ強制的にフォーカスを移動してはいけません。ユーザーが入力中の内容や、現在読んでいる場所を失う可能性があるためです。status ロールの仕様でも、状態が変化したという理由で status へフォーカスを移動しないよう求めています。
サンプルでは、送信後もフォーカスを入力欄に残します。生成中に表示される「停止」ボタンは通常の <button> であるため、必要なユーザーは Tab キーで移動して操作できます。「停止」ボタンを押した場合も、ボタンが消えた時点でフォーカスを入力欄へ戻すため、続けて質問を入力できます。
VoiceOver と Chrome で確認する
実際に VoiceOver と Chrome でサンプルを確認した結果、以下のような挙動になることが確認できます。
- フォームのサブミット後「回答を生成しています」と 1 回通知される
- ストリーミング中の回答本文は自動的に読み上げられない
- 完了時に「回答が完了しました」と 1 回通知される
- 送信後と完了後のフォーカスは入力欄に残る
- 履歴を読み進めると「あなたのメッセージ」「AI の回答」という見出しを確認できる
実際のコードは以下の Codepen で確認できます。
まとめ
role="log"はチャット履歴を表す標準的なロールであり、暗黙にaria-live="polite"を持つ- LLM の回答を
role="log"の中で直接ストリーミングすると、生成途中のテキストまで細切れに通知される可能性がある - ChatGPT と Claude は、ストリーミングされる回答本文とは別の
role="status"から生成状態を通知している - チャット UI ライブラリの対応は統一されていない。どのライブラリを選んでも、回答の開始と完了を短いテキストで通知する仕組みはアプリケーション側で用意する必要がある
- サンプルでは履歴を
role="log" aria-live="off"とし、別のrole="status"から生成開始、完了、停止、エラーを短く通知した aria-busyだけに通知制御を依存せず、対象とするスクリーンリーダーとブラウザで検証する
参考
- WAI-ARIA 1.2: log role
- WAI-ARIA 1.2: status role
- WAI-ARIA 1.2: aria-busy
- ARIA23: Using role=log to identify sequential information updates
- Understanding Success Criterion 4.1.3: Status Messages
- Feed Pattern | APG | WAI | W3C
- OpenClaw: Screen readers announce every token during streaming
- assistant-ui
- Vercel AI Elements
- CopilotKit
- shadcn/ui Chat Components



