Reactで、動画に字幕(WebVTT)を付けて切り替える方法
動画の字幕は、WebVTT 形式のファイルを <track> 要素で <video> に付けると、ブラウザが映像の上に表示できます(default 属性がなければ、最初は表示されません)。どの字幕を出すかは、video.textTracks の各字幕の mode を showing か disabled にして切り替えます。React では、選んだ言語を state に持ち、useEffect で mode に反映させます。
ここでは、日本語・English・オフを <select> で切り替えるコンポーネントを作ります。あわせて、<select> 以外から字幕が変わったときに <select> の表示を合わせる方法、字幕を映像の外に自分で表示する方法、字幕が出ないときに確かめることを、検証の結果とともに書きます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、ヘッドレスの Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。動画は ffmpeg で作った12秒の検証用ファイル(VP9 の WebM)で、検証用のサーバーもこの環境の中で動かしました。Firefox と Safari、スマートフォンのブラウザでは試していません。ブラウザの操作部品にある字幕メニューからの切り替えは試しておらず、その代わりに mode をコードから書き換えて確かめました。

WebVTT の字幕ファイル
字幕ファイルは、先頭に WEBVTT と書き、その後に「表示する時間」と「字幕の文」の組を空行で区切って並べます。検証に使った日本語の字幕は次のとおりです。英語の字幕(en.vtt)も、同じ時間で文だけを英語にしています。
WEBVTT
00:00:01.000 --> 00:00:04.000
猫が窓の外を見ています
00:00:05.000 --> 00:00:08.000
鳥が飛んできました
00:00:09.000 --> 00:00:11.500
猫はしっぽを振っています
MDN の WebVTT の形式の説明によると、文字コードは UTF-8、MIME タイプは text/vtt です。先頭の WEBVTT を省いたときにどうなるかは、後の「字幕が出ないとき」の節に書きます。
track 要素を並べ、select で mode を切り替える
SubtitledVideo は、動画の URL と字幕の一覧を受け取り、字幕ごとに <track> を並べます。JSX では、HTML の srclang 属性を srcLang と書きます。
import { useEffect, useRef, useState } from 'react';
export type Subtitle = { srcLang: string; label: string; src: string };
type Props = { src: string; subtitles: Subtitle[]; crossOrigin?: 'anonymous' };
// 字幕の言語(srcLang)か、字幕なしの 'off'
type Lang = string;
export function SubtitledVideo({ src, subtitles, crossOrigin }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const [lang, setLang] = useState<Lang>('off');
// 選んだ言語の字幕だけを showing にし、ほかは disabled にする
useEffect(() => {
const tracks = videoRef.current!.textTracks;
for (const track of Array.from(tracks)) {
track.mode = track.language === lang ? 'showing' : 'disabled';
}
}, [lang, subtitles]);
// ブラウザの字幕メニューなど、ほかの所で mode が変わったら選択を合わせる
useEffect(() => {
const tracks = videoRef.current!.textTracks;
const onChange = () => {
const showing = Array.from(tracks)
.filter((t) => t.mode === 'showing')
.map((t) => t.language);
// 新しく showing になった字幕を優先する。どれも showing でなければ 'off'
setLang((prev) => showing.find((l) => l !== prev) ?? (showing.length > 0 ? prev : 'off'));
};
tracks.addEventListener('change', onChange);
return () => tracks.removeEventListener('change', onChange);
}, []);
return (
<div>
<video ref={videoRef} src={src} controls crossOrigin={crossOrigin} width={640}>
{subtitles.map((s) => (
<track key={s.src} kind="subtitles" src={s.src} srcLang={s.srcLang} label={s.label} />
))}
</video>
<label>
字幕
<select value={lang} onChange={(e) => setLang(e.target.value)}>
<option value="off">オフ</option>
{subtitles.map((s) => (
<option key={s.srcLang} value={s.srcLang}>{s.label}</option>
))}
</select>
</label>
</div>
);
}
使う側では、subtitles={[{ srcLang: 'ja', label: '日本語', src: '/ja.vtt' }, { srcLang: 'en', label: 'English', src: '/en.vtt' }]} のように渡します。kind="subtitles" の <track> には srclang が必須だと、MDN の track 要素の説明にあります。コードでは、この srcLang が TextTrack の language になるので、言語で字幕を選んでいます。そのため、このコンポーネントは同じ言語の字幕が2つある場合には対応していません。
切り替えの中心は、最初の useEffect です。TextTrack の mode は disabled(使わない)、hidden(読み込むが表示しない)、showing(映像の上に表示する)の3つで、選んだ言語だけを showing にし、ほかは disabled にします。
字幕ファイルは、選んだときに初めて読み込まれる
<track> に default 属性を付けなければ、mode は最初 disabled です。MDN の説明では、disabled の字幕はファイルを読み込みません。Playwright で <select> を操作し、サーバーに届いたリクエストと各字幕の状態を記録したところ、次のようになりました。
| 操作 | サーバーへのリクエスト | 日本語の字幕 | 英語の字幕 |
|---|---|---|---|
| 開いた直後 | なし | disabled、cues は null |
disabled、cues は null |
| 日本語を選ぶ | /ja.vtt |
showing、cues は3件 |
disabled、cues は null |
| English を選ぶ | /en.vtt |
disabled、cues は null |
showing、cues は3件 |
| オフを選ぶ | なし | disabled、cues は null |
disabled、cues は null |
| もう一度日本語を選ぶ | なし | showing、cues は3件 |
disabled、cues は null |
ページを開いただけでは字幕ファイルを1つも取りに行かず、選んだ言語のファイルだけが読み込まれました。2回目に日本語を選んだときは、リクエストは出ませんでした。disabled に戻した字幕は、読み込んだ後でも cues が null になります。
このコンポーネントは <track> に default を付けず、どの字幕を出すかを state だけで決めています。なお、MDN の mode の説明には、Safari で独自の操作部品を作るときは default 属性も必要だと書かれています。Safari では試していないので、Safari にも対応させる場合は確かめてください。
select 以外から字幕が変わったときに、表示を合わせる
mode は、<select> 以外からも変えられます。たとえば、ほかのスクリプトが mode を書き換えることがあります。そうして字幕が変わると、<select> の表示と実際の字幕がずれます。これを防ぐのが2つ目の useEffect です。textTracks の change イベントは、字幕の mode が変わると発生するので、そのときに showing の字幕を調べて state に入れます。
最初は、change が届いたら「最初に見つかった showing の字幕」を state に入れる書き方にしていました。日本語を表示している状態で、英語の字幕の mode をコードから showing にしてみると、日本語と英語の両方が showing のまま残り、<select> も日本語のままでした。
原因は、最初に見つかる showing が、もともと表示していた日本語だったことです。setLang('ja') は、いまの state と同じ値です。同じ値で更新したときは、React は再レンダーを省きます。そのため、最初の useEffect も動きません。
そこで、上のコードでは、showing の字幕のうち、いまの state と違う言語を優先して選ぶようにしました。いまの state は、setLang に関数を渡して受け取ります(React の useState の説明にある、前の state をもとに更新する書き方です)。この書き方で同じ操作をすると、次のようになりました。
- 英語の字幕を
showingにした後:英語だけがshowingになり、日本語はdisabledに戻った。<select>は English になった - 続けて英語の字幕を
disabledにした後:どちらもdisabledになり、<select>はオフになった
state が 'en' に変わったので、最初の useEffect が動いて日本語を disabled にした、という流れです。
字幕を映像の外に、自分で表示する
字幕を映像に重ねず、動画の下に大きく出したいときは、mode を hidden にします。hidden の字幕は、ファイルを読み込んで時間に合わせて cuechange イベントを出しますが、映像の上には何も描きません。いま表示すべき字幕は activeCues に入っているので、それを state に入れて表示します。
import { useEffect, useRef, useState } from 'react';
import type { Subtitle } from './SubtitledVideo';
// 字幕を video の上には描かせず、video の下に自分で表示する
export function CaptionBelow({ src, subtitle }: { src: string; subtitle: Subtitle }) {
const trackRef = useRef<HTMLTrackElement>(null);
const [text, setText] = useState('');
useEffect(() => {
const track = trackRef.current!.track;
track.mode = 'hidden'; // 字幕を読み込むが、video の上には表示しない
const onCueChange = () => {
const cues = Array.from(track.activeCues ?? []) as VTTCue[];
setText(cues.map((c) => c.text).join('\n'));
};
track.addEventListener('cuechange', onCueChange);
return () => track.removeEventListener('cuechange', onCueChange);
}, []);
return (
<div>
<video src={src} controls width={640}>
<track ref={trackRef} kind="subtitles" src={subtitle.src} srcLang={subtitle.srcLang} label={subtitle.label} />
</video>
<p className="caption" aria-live="polite">{text}</p>
</div>
);
}
<track> に ref を付けると、HTMLTrackElement の track プロパティから、その要素の TextTrack を直接取り出せます。textTracks の何番目かを数えずに済みます。
再生しながら1秒ごとに下の表示を記録したところ、1.5秒・2.5秒・3.6秒では「猫が窓の外を見ています」、字幕のない4.6秒では空、5.6秒では「鳥が飛んできました」でした。字幕の時間が終わると cuechange が起き、activeCues が空になるので、表示も消えます。

<p> に付けた aria-live="polite" は、表示が変わったことをスクリーンリーダーに知らせるための指定です。スクリーンリーダーでの読み上げは試していません。
字幕が出ないときに確かめること
字幕ファイルの読み込みに失敗すると、<track> で error イベントが起き、readyState が 3(ERROR)になります。エラーは画面に表示されないので、字幕が出ないときは <track> の readyState とブラウザのコンソールを確かめます。次の5つの場合を Chromium で試しました。
| 試した場合 | 結果 |
|---|---|
先頭の WEBVTT の行がないファイル |
サーバーは 200 を返したが error。readyState は 3、cues は0件 |
Content-Type: text/plain で返したファイル |
読み込めた。cues は3件 |
別のオリジン(ポートが違うサーバー)の字幕を、crossOrigin なしで読む |
リクエストが出ずに error。コンソールに「Unsafe attempt to load URL …」 |
crossOrigin="anonymous" を付け、サーバーが Access-Control-Allow-Origin を返さない |
error。コンソールに CORS で止められたというエラー |
crossOrigin="anonymous" を付け、サーバーが Access-Control-Allow-Origin: * を返す |
読み込めた。cues は3件 |
WEBVTT の行がないファイルは、サーバーが正しく返していても字幕として読めません。リクエスト自体は 200 で成功しているので、通信の記録だけでは原因が分かりません。
字幕ファイルをページと別のオリジン(CDN など)に置くときは、<video> に crossOrigin を付け、字幕のサーバーで CORS を許可する必要があります。MDN の track 要素の説明にも、src は文書と同じオリジンでなければならず、親の <video> に crossorigin 属性があればその限りではない、と書かれています。上の SubtitledVideo が crossOrigin を props で受け取れるようにしているのは、この場合のためです。
text/plain で返したファイルは、検証環境の Chromium では読み込めました。ただ、WebVTT の MIME タイプは text/vtt なので、サーバーの設定では text/vtt を返すようにしておきます。





