Reactで、文章を読み上げるボタンを作る方法
文章を読み上げるボタンは、ブラウザに組み込まれている Web Speech API の speechSynthesis で作れます。音声ファイルを用意したり、音声合成の API を自分で呼んだりする必要はありません。読ませたい文章から SpeechSynthesisUtterance を作り、speechSynthesis.speak() に渡すだけで読み上げが始まります。
ここでは、段落ごとに「読み上げる」ボタンを置き、読み上げ中は同じボタンが「止める」に変わるようにします。あわせて、声の一覧が後から届く場合の対応と、別の段落のボタンを押したときやコンポーネントが画面から消えたときに読み上げを止める処理を入れます。どれも、その処理を外したコードと比べて確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用のビルドを StrictMode で包んで動かしました。声は、speech-dispatcher 0.12(rc2)を通して Open JTalk と eSpeak NG のものを使いました。この Chromium は、--enable-speech-dispatcher を付けて起動しないと声の一覧が空のままでした。操作は Playwright で送りました。音は人の耳で聞く代わりに、何も鳴らない PulseAudio の出力先に出して録音し、音量を測りました。日本語として正しく読めているかは確かめていません。Chrome・Edge・Safari・Firefox の製品版、Windows・macOS・スマートフォン、それぞれの OS に入っている声は試していません。

日本語の声を探すフック
useSpeech.ts には、2つのフックを入れます。1つ目は声を選ぶフックです。読み上げに使う声は speechSynthesis.getVoices() で一覧を取り、その中から選びます。この一覧は、ページを開いた直後には空のことがあります。voiceschanged は、getVoices() が返す一覧が変わったときに起きるイベントです(MDN の voiceschanged イベントの説明)。そこで、このイベントでも探し直します。
import { useCallback, useEffect, useState } from 'react';
// ブラウザに入っている日本語の声を探す。一覧はあとから届くことがあるので、voiceschanged でも探し直す
export function useJapaneseVoice(): SpeechSynthesisVoice | null {
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null);
useEffect(() => {
if (!('speechSynthesis' in window)) return;
const pick = () => {
const ja = speechSynthesis.getVoices().filter((v) => v.lang.toLowerCase().startsWith('ja'));
setVoice(ja.find((v) => v.default) ?? ja[0] ?? null);
};
pick();
speechSynthesis.addEventListener('voiceschanged', pick);
return () => speechSynthesis.removeEventListener('voiceschanged', pick);
}, []);
return voice;
}
lang が ja で始まる声のうち、その環境の既定の声(default が true)があればそれを、なければ先頭の声を使います。日本語の声がなければ null を返します。Article では、そのときボタンを押せないようにします。
検証環境では、ページを開いた直後の一覧は空でした。開いた直後にも voiceschanged が1回起きましたが、そのときも一覧は空でした。約0.27秒後に起きた2回目で声が 13,363 個そろい、そのうち lang が ja で始まる声は 103 個でした。その中に default が true の声はなく、先頭の Default openjtalk が選ばれました。
voiceschanged のリスナーを登録しないコードでは、開いた直後にしか探さず、空の一覧しか見ないので、3秒たってもボタンは押せないままでした。声が後から届いても、それを拾えません。
読み上げと停止を受け持つフック
2つ目は、読み上げと停止を受け持つフックです。段落ごとにボタンがあっても、読み上げにはどれも同じ window.speechSynthesis を使います。そこで、どの段落を読み上げているかを speakingId として、段落を並べる親のコンポーネントで持つようにしました。
// どの文章を読み上げているかを id で持つ。同時に読み上げるのは1つだけ
export function useSpeech(voice: SpeechSynthesisVoice | null, rate: number) {
const [speakingId, setSpeakingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const speak = useCallback(
(id: string, text: string) => {
speechSynthesis.cancel(); // 読み上げ中のものがあれば止める(後ろに並べない)
const u = new SpeechSynthesisUtterance(text);
u.lang = 'ja-JP';
if (voice) u.voice = voice;
u.rate = rate;
u.onstart = () => setSpeakingId(id);
u.onend = () => setSpeakingId(null);
u.onerror = (e) => {
setSpeakingId(null);
// cancel() で止めたときも error が届く。これはエラーとして表示しない
if (e.error !== 'interrupted' && e.error !== 'canceled') {
setError(`読み上げられませんでした(${e.error})`);
}
};
setError(null);
speechSynthesis.speak(u);
},
[voice, rate],
);
const stop = useCallback(() => speechSynthesis.cancel(), []);
// 画面から消えたら、読み上げも止める
useEffect(
() => () => {
if ('speechSynthesis' in window) speechSynthesis.cancel();
},
[],
);
return { speakingId, error, speak, stop };
}
ボタンの表示は、クリックしたときではなく、発話の start イベントで「止める」に変え、end か error で「読み上げる」に戻します。検証環境では、クリックから「止める」に変わるまで 0.1 秒ほどでした。1つ目の段落は、start から end まで約3.9秒かかり、その後ボタンが「読み上げる」に戻りました。読み上げの前後を含めて8秒間録音した音量の平均は -27.2dB で、何も読み上げずに3秒間録音したときの -91.0dB より大きく、音が出ていました。
読み上げる前に cancel() を呼ぶ
speak() は、渡した発話をすぐに読むわけではありません。MDN の speak() の説明によると、発話は順番待ちの列(キュー)に足され、前に並んだものを読み終えてから読まれます。そのため speak() の前に、列を空にして読み上げ中のものも止める cancel() を呼んでいます。
1つ目の段落の読み上げ中に2つ目のボタンを押すと、記事のコードでは1つ目がすぐに止まり、2つ目の読み上げが始まりました。ボタンも、1つ目が「読み上げる」に戻り、2つ目が「止める」になりました。
cancel() を呼ばないコードでは、2つ目の発話は列に並ぶだけでした。押して2.5秒後も speechSynthesis.pending が true で、ボタンは1つ目が「止める」のまま、2つ目も「読み上げる」のままでした。2つ目の読み上げが始まったのは、1つ目を読み終えた後です。押した直後に反応がないので、ボタンが効いていないように見えます。
cancel() で止めたときの error は表示しない
cancel() で止めた発話には、error イベントが届きます。MDN の SpeechSynthesisErrorEvent.error の説明によると、読み始めた後に止められたときは interrupted、読み始める前に列から外されたときは canceled になります。検証環境でも、「止める」を押したときと、別の段落に切り替えたときの両方で、止めた発話に interrupted が届きました。
これはユーザーが自分で止めたものなので、onerror ではボタンを戻すだけにして、メッセージは出しません。それ以外の値のときだけ、画面にメッセージを出します。ほかの値が届いたときの表示は、--enable-speech-dispatcher を付けずに起動して声がない状態にし、声がなくてもボタンを押せるように変えたコードで確かめました。synthesis-failed が届き、「読み上げられませんでした(synthesis-failed)」と表示されました。
画面から消えたら止める
speechSynthesis の読み上げは、それを始めたコンポーネントが画面から消えても止まりません。クリーンアップ関数で cancel() を呼ばないコードで、読み上げ中に Article を画面から外すと、0.3秒後も speechSynthesis.speaking は true のままでした。React の公式リファレンスにある useEffect の説明によると、クリーンアップ関数はコンポーネントがアンマウントされるときに実行されます。記事のコードでは、そこで cancel() を呼ぶので、speaking は false になりました。
クリーンアップ関数の中で speechSynthesis があるかを確かめているのは、speechSynthesis がないブラウザでもこのフックは呼ばれ、画面から外したときにクリーンアップ関数も実行されるからです。
開発中の StrictMode では、マウントの直後にもクリーンアップ関数が1回実行されます。検証環境でも、ページを開いた直後に cancel() が1回呼ばれましたが、まだ何も読み上げていないので、影響はありませんでした。
ボタンと段落のコンポーネント
SpeakButton.tsx のボタンは、読み上げ中かどうかで、ラベルと onClick を切り替えるだけです。
type Props = {
speaking: boolean;
disabled: boolean;
onSpeak: () => void;
onStop: () => void;
};
export function SpeakButton({ speaking, disabled, onSpeak, onStop }: Props) {
return (
<button
type="button"
className={`speak${speaking ? ' speaking' : ''}`}
onClick={speaking ? onStop : onSpeak}
disabled={disabled}
aria-pressed={speaking}
>
{speaking ? '■ 止める' : '▶ 読み上げる'}
</button>
);
}
段落を並べる Article.tsx で、2つのフックを使います。speechSynthesis がないブラウザでは、ボタンの代わりにメッセージを出します。検証では、ページを読み込む前に speechSynthesis を消して開くと、「このブラウザは読み上げに対応していません。」と表示され、その後 Article を画面から外してもエラーは出ませんでした。
import { useState } from 'react';
import { SpeakButton } from './SpeakButton';
import { useJapaneseVoice, useSpeech } from './useSpeech';
const PARAGRAPHS = [
{ id: 'p1', text: '猫は一日の半分以上を寝て過ごします。' },
{ id: 'p2', text: '暖かい場所を見つけるのが得意で、冬は日なたから動きません。' },
];
export function Article() {
const supported = 'speechSynthesis' in window;
const voice = useJapaneseVoice();
const [rate, setRate] = useState(1);
const { speakingId, error, speak, stop } = useSpeech(voice, rate);
if (!supported) return <p>このブラウザは読み上げに対応していません。</p>;
return (
<div className="article">
<label>
速さ
<select value={rate} onChange={(e) => setRate(Number(e.target.value))}>
<option value={0.75}>ゆっくり</option>
<option value={1}>ふつう</option>
<option value={1.5}>速い</option>
</select>
</label>
{voice === null && <p className="voice">日本語の声が見つかりません。</p>}
{PARAGRAPHS.map((p) => (
<section key={p.id} className="para">
<p>{p.text}</p>
<SpeakButton
speaking={speakingId === p.id}
disabled={voice === null}
onSpeak={() => speak(p.id, p.text)}
onStop={stop}
/>
</section>
))}
{error && <p role="alert">{error}</p>}
</div>
);
}
見た目は CSS で付けます。読み上げ中のボタンだけ水色で塗ります。
body { font: 16px/1.7 sans-serif; color: #1f2a30; margin: 16px; }
.para { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin: 12px 0; }
.para p { margin: 0; }
.speak { font: inherit; padding: 4px 14px; border: 2px solid #00c8e8; border-radius: 999px; background: #fff; color: #067a98; cursor: pointer; }
.speak.speaking { background: #00c8e8; color: #1f2a30; }
.speak:disabled { border-color: #9aa5ab; color: #9aa5ab; cursor: default; }
速さ(rate)が効くかどうかは声しだい
読み上げの速さは、発話の rate で決めます。MDN の rate の説明によると、値は 0.1 から 10 までで、1 がその声の普通の速さです。2 なら2倍、0.5 なら半分の速さを表します。ただし、声や読み上げのエンジンによっては、使える範囲がさらに狭いことがあるとも書かれています。
検証環境で同じ文を読ませ、start から end までの時間を測ると、声によって結果が分かれました。
| 声 | rate 0.75 | rate 1 | rate 1.5 |
|---|---|---|---|
| Default openjtalk | 5.48秒 | 5.49秒 | 5.48秒 |
| Japanese espeak-ng | 15.40秒 | 13.08秒 | 7.81秒 |
この記事のコードで選ばれた Default openjtalk では、rate を変えても読み上げの長さは変わりませんでした。Japanese espeak-ng では、rate を上げるほど短くなりました。速さの切り替えを置くなら、使う声で実際に変わるかを確かめる必要があります。





