Reactでaria-liveを使い、画面の変化を読み上げてもらう方法

​​2026年10月24日​React​aria-live,Playwright,TypeScript,アクセシビリティ,スクリーンリーダー

ボタンを押したあとに出る「保存しました」のような短い知らせは、画面を見ていればすぐに気づきます。スクリーンリーダーの利用者には、aria-live を付けた領域(ライブリージョン)に文を書き込むことで伝えられます。React で書くときに気をつけるのは、領域を最初から DOM に置いておくことと、同じ文が続いても DOM が変わるようにすることの2つです。

筆者は検証環境で、書き方を変えた5つのコンポーネントを作り、Chromium のアクセシビリティツリーと、領域の DOM の変化を記録しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。確かめたのは、アクセシビリティツリーに出る値と、MutationObserver で見た DOM の変化です。スクリーンリーダーは入れておらず、実際に読み上げられるかどうかは試していません。読み上げについての記述は、MDN の説明をもとにしています。

ポイント

role="status" や aria-live の付いた要素は、文がないときも空のまま描画しておき、中の文だけを state で変えます。同じ文を続けて知らせたいときは、いったん空にしてから、setTimeout で処理を後に回して文を入れます。

領域を最初から置き、中の文だけを変える

MDN の aria-live のページでは、aria-live は空の要素に付け、ページが更新されたときにその要素へ短い知らせを書き込む使い方が説明されています。値は、急がない知らせの polite と、すぐに伝える assertive などです。

React でも、属性の名前は HTML と同じ aria-live です。React の公式リファレンスにある、共通の props の説明に、ARIA の属性の名前は HTML とまったく同じだと書かれています。

検証に使った、カートに追加したことを知らせるコンポーネントです。領域の <div> は、文が空のときも描画しています。

tsx
function CartAlwaysMounted() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState('');

  const add = () => {
    const next = count + 1;
    setCount(next);
    setMessage(`カートに追加しました(${next}個)`);
  };

  return (
    <section id="always">
      <button onClick={add}>カートに追加</button>
      <div role="status" aria-live="polite">{message}</div>
    </section>
  );
}

ボタンを2回押し、領域の中の変化を MutationObserver で記録しました。1回目は空の領域にテキストが入り、2回目はそのテキストの中身が書き換わりました。領域の <div> そのものは、作り直されていません。

text
[always] クリック 1回目: 追加: テキスト "カートに追加しました(1個)"
[always] クリック 2回目: 文字の変更: "カートに追加しました(2個)"

role="status" と role="alert" の違い

role="status" だけを付けても、ライブリージョンになります。MDN の status ロールのページによると、status には暗黙の aria-live="polite" と aria-atomic="true" があります。MDN の alert ロールのページによると、role="alert" は aria-live="assertive" と aria-atomic="true" を付けたのと同じです。aria-atomic="true" は、変わった部分だけでなく、領域の全体をまとめて伝える指定です(MDN の aria-atomic のページ)。

ただし、MDN のライブリージョンのガイドにある、暗黙のライブリージョンの属性を持つロールの表は、互換性を高めるために、role="status" にも aria-live="polite" を重ねて付けるよう勧めています。この記事では、カートの例と、あとで作るフックの領域に両方を付けました。同じ文を入れる例だけは、暗黙の値を確かめるために role="status" だけにしています。role="alert" については、同じ表に、aria-live を重ねると iOS の VoiceOver で二重に読み上げられる問題があると書かれています。そのため、role="alert" には aria-live を足していません。

Chromium のアクセシビリティツリーで確かめました。あとで出てくる、同じ文を入れる例の role="status"(出力の [same])は、aria-live を書いていませんが、live=polite になっています。role="alert" の要素は live=assertive でした。検証の出力から、最初の表示のときの部分を抜き出したものです。

text
[アクセシビリティツリー] 最初の表示
  [always] role=status live=polite atomic=true relevant=additions text name=""
  [same] role=status live=polite atomic=true relevant=additions text name=""
  [announcer] role=status live=polite atomic=true relevant=additions text name=""
  [alert] role=alert live=assertive atomic=true relevant=additions text name=""

alert は、すぐに知らせる必要があるものだけに使います。MDN の alert ロールのページには、例の1つに、フォームの欄に正しくない値が入力された場合が挙がっています。検証では、メールアドレスの欄を離れたときに、形式が正しくなければ role="alert" の段落に文を入れました。

tsx
function EmailField() {
  const [error, setError] = useState('');
  return (
    <section id="alert">
      <label>
        メールアドレス
        <input
          type="email"
          onBlur={(e) => setError(e.currentTarget.validity.valid ? '' : 'メールアドレスの形式が正しくありません')}
        />
      </label>
      <p role="alert">{error}</p>
    </section>
  );
}

abc と入力して欄を離れると、role="alert" の段落にテキストが入りました。

text
[alert] 誤った値で欄を離れた: 追加: テキスト "メールアドレスの形式が正しくありません"

文があるときだけ領域を描画すると、読み上げられないことがある

React では、{message && <div>…</div>} のように、表示するものがあるときだけ要素を描画する書き方をよく使います。ライブリージョンをこう書くと、領域と文が同時に DOM に入ります。

tsx
{message && <div role="status" aria-live="polite">{message}</div>}

検証では、1回目のクリックで、文の入った <div role="status"> が丸ごと追加されました。アクセシビリティツリーでも、条件付きで描画した領域(出力の [conditional])は最初の表示にはなく、操作のあとに初めて現れました。

text
[conditional] クリック 1回目: 追加: <div role="status"> "カートに追加しました(1個)"
[conditional] クリック 2回目: 文字の変更: "カートに追加しました(2個)"

MDN のライブリージョンのガイドによると、支援技術がふつう読み上げるのは、ライブリージョンの中身が変わったときです。ガイドは、中身を更新する前に領域を用意し、空の領域を支援技術に認識させてから中身を入れるよう勧めています。いちばん確実なのは、最初のマークアップに領域を入れておくことだとも書かれています。この書き方では、1回目の知らせが読み上げられないおそれがあります。

ただし、同じガイドの注記によると、role="alert" は多くの場合、文の入った要素がページに追加されたときにも読み上げられます。仕様には書かれていない、ブラウザと支援技術の側の扱いです。一方で、MDN の alert ロールのページの説明には、文の入った role="alert" の要素を追加しても、ふつうは読み上げにつながらないとあります。どちらの説明に従っても、領域を先に置いておく書き方なら問題になりません。

同じ文を続けて入れても、DOM は変わらない

「保存しました」のように、毎回同じ文で知らせる場面もあります。state に同じ文字列を入れると、React は DOM を書き換えません。

tsx
export function SaveSameText() {
  const [message, setMessage] = useState('');
  return (
    <section id="same">
      <button onClick={() => setMessage('保存しました')}>保存</button>
      <div role="status">{message}</div>
    </section>
  );
}

2回押したところ、2回目は領域の中に何の変化もありませんでした。

text
[same] クリック 1回目: 追加: テキスト "保存しました"
[same] クリック 2回目: 変化なし

MDN の alert ロールのページにある、中身を書き換える例でも、前と同じ文を入れても変化とはみなされず、読み上げにつながらないと説明されています。対処として挙がっているのは、文を入れる前に、いったん中身を空にすることです。

空にしてから文を入れるフックを作る

いったん空にする処理を、フックにまとめました。announce を呼ぶと、state を空にしてから、setTimeout で処理を後に回して文を入れます。MDN の alert ロールの例は、空にする処理と文を入れる処理を続けて書いていて、setTimeout は使っていません。一方で、MDN のライブリージョンのガイドは、JavaScript で領域を作ったときに、中身の更新を setTimeout() などで後に回すよう勧めています。このフックでは、空にする変化と文を入れる変化を別々のタイミングで起こすために、同じ方法を使っています。

tsx
import { useCallback, useEffect, useRef, useState } from 'react';

// 読み上げてほしい文を、常に置いてある領域に書き込むためのフック
export function useAnnouncer() {
  const [message, setMessage] = useState('');
  const timer = useRef<number | undefined>(undefined);

  const announce = useCallback((text: string) => {
    // いったん空にしてから、setTimeout で後に回して文を入れる(同じ文が続いても中身が変わる)
    setMessage('');
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setMessage(text), 0);
  }, []);

  // アンマウントしたら、待っている書き込みを取り消す
  useEffect(() => () => window.clearTimeout(timer.current), []);

  const region = (
    <div role="status" aria-live="polite" className="visually-hidden">
      {message}
    </div>
  );

  return { announce, region };
}

使う側は、region をどこかに描画しておき、知らせたいときに announce を呼びます。

tsx
export function SaveWithAnnouncer() {
  const { announce, region } = useAnnouncer();
  return (
    <section id="announcer">
      <button onClick={() => announce('保存しました')}>保存</button>
      {region}
    </section>
  );
}

2回押したところ、2回目は、テキストが消える変化と、テキストが入る変化が、MutationObserver に別々の通知で届きました。同じ文でも、領域の中身は一度空になってから変わっています。

text
[announcer] クリック 1回目: 追加: テキスト "保存しました"
[announcer] クリック 2回目: 削除: テキスト "保存しました" → 次の通知で 追加: テキスト "保存しました"
上の枠は文をそのまま入れる書き方、下の枠はuseAnnouncerの書き方。保存ボタンを2回ずつ押すと、上の枠は2回目に「DOM の変化:変化なし」と出て、下の枠は2回目に「文が消えた → 文が入った」と出る
保存ボタンを2回ずつ押したときの、領域の中身と DOM の変化

GIF の「領域の中身」と「DOM の変化」は、撮影用のスクリプトが書き足した表示です。下の枠の領域は画面からは隠しているので、中身はこの表示で見せています。押した瞬間は、押したボタンと回数の表示、変わった値をピンクにしています。

setTimeout の待ち時間は 0 にしました。MDN は待ち時間の長さを示していません。0 で、空にする変化と文を入れる変化が分かれることは確かめましたが、スクリーンリーダーがどちらも拾うかどうかは試していません。

画面からは隠し、display: none は使わない

知らせの文を画面に出さないときは、領域を見えなくするだけにします。MDN の alert ロールのページにある、画面から隠した例によると、display: none で隠すと支援技術からも隠れ、変化が伝わりません。検証では、次の CSS の visually-hidden クラスを使いました。

css
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

この CSS で隠した useAnnouncer の領域も、アクセシビリティツリーには live=polite の status として出ていました。

書くときのポイント

React でライブリージョンを使うときは、次の3つを押さえておけば足ります。

  • 領域の要素は、文が空のときも描画しておく。条件付きで描画するのは、中の文のほう
  • ふだんの知らせは role="status"(互換性のため aria-live="polite" も重ねる)、すぐに伝えるべき誤りなどは role="alert" にする
  • 同じ文を続けて知らせるときは、いったん空にしてから、setTimeout で処理を後に回して文を入れる

この3つを1つのフックにまとめておくと、どのコンポーネントからも announce('保存しました') のように呼ぶだけで知らせを出せます。

​Posted by 86