Reactで、動画をピクチャーインピクチャーで再生する方法
動画を見ながら別のタブや資料を開きたいとき、動画だけを画面の隅の小さな窓に出して、再生を続けられます。これがピクチャーインピクチャー(PiP)です。React では、ボタンのクリックの中で <video> 要素の requestPictureInPicture() を呼び、小さい窓かどうかは、動画に届くイベントを受けて state に反映します。
ここでは、小さい窓への出し入れをまとめたフック usePictureInPicture を作りました。小さい窓の×ボタンで閉じたときや、動画のコンポーネントが消えたときにどうなるかも、実際の画面で確かめています。
検証環境は 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 です。小さい窓のボタンや、動画の標準のメニューの操作は、仮想ディスプレイ(Xvfb)の上で画面ありの Chromium を動かし、xdotool で本物のマウスを動かして押しました。動画は音声なしのテスト映像です。Firefox と Safari、スマートフォンのブラウザ、タブを切り替えたときに自動で小さい窓にする機能、<video> 以外の HTML を小さい窓に出す Document Picture-in-Picture API は試していません。

どのブラウザで使えるか
MDN の互換性データでは、requestPictureInPicture() は Chrome と Edge が69から、Android 版 Chrome が105から、Safari が13.1から、Firefox が153から対応しています。
使えるかどうかは、document.pictureInPictureEnabled で調べます。対応していないブラウザではこのプロパティ自体がないので、in で有無を確かめてから値を読みます。Permissions Policy で禁止されたページでは、値が false になります。どちらの場合も、後で作るコンポーネントはボタンを出しません。Chromium でこのプロパティを消して試したところ、ボタンは出ず、動画だけが表示されました。
小さい窓への出し入れをまとめたフック
小さい窓に出すのは video.requestPictureInPicture()、戻すのは document.exitPictureInPicture() です。今どの動画が小さい窓にあるかは、document.pictureInPictureElement で分かります。
// usePictureInPicture.ts
import { useCallback, useEffect, useState, type RefObject } from 'react';
export function isPictureInPictureSupported(): boolean {
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled;
}
export function usePictureInPicture(videoRef: RefObject<HTMLVideoElement | null>) {
const [isActive, setIsActive] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onEnter = () => setIsActive(true);
const onLeave = () => setIsActive(false);
video.addEventListener('enterpictureinpicture', onEnter);
video.addEventListener('leavepictureinpicture', onLeave);
return () => {
video.removeEventListener('enterpictureinpicture', onEnter);
video.removeEventListener('leavepictureinpicture', onLeave);
// 小さい窓で再生したままコンポーネントが消えると、窓だけが残るので閉じる
if (document.pictureInPictureElement === video) {
document.exitPictureInPicture().catch(() => {});
}
};
}, [videoRef]);
const toggle = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
setError(null);
try {
if (document.pictureInPictureElement === video) {
await document.exitPictureInPicture();
} else {
await video.requestPictureInPicture();
}
} catch (e) {
setError(e instanceof DOMException ? `${e.name}: ${e.message}` : String(e));
}
}, [videoRef]);
return { isActive, error, toggle };
}
isActive は、toggle の中では変えていません。動画に届く enterpictureinpicture と leavepictureinpicture のイベントだけで切り替えます。小さい窓は、自分のボタン以外の操作でも開いたり閉じたりするからです(次の節で確かめます)。
React の JSX には onEnterPictureInPicture のような書き方がありません。react-dom 19.3.1 と @types/react 19.3.0 のパッケージのどこにも、このイベント名は出てきません。そのため、useEffect の中で addEventListener を使って登録しています。型は TypeScript 7.0.2 の標準の型定義(lib.dom)に入っていて、自分で書き足さなくても tsc が通りました。
コンポーネント側では、このフックにボタンを付けます。
// PipVideo.tsx
import { useRef, useState } from 'react';
import { isPictureInPictureSupported, usePictureInPicture } from './usePictureInPicture';
type Props = { src: string; label: string };
export function PipVideo({ src, label }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isActive, error, toggle } = usePictureInPicture(videoRef);
const [ready, setReady] = useState(false);
const supported = isPictureInPictureSupported();
return (
<section className="player" data-label={label}>
<video
ref={videoRef}
src={src}
controls
muted
playsInline
onLoadedMetadata={() => setReady(true)}
/>
{supported && (
<button type="button" onClick={toggle} disabled={!ready}>
{isActive ? '元の場所に戻す' : '小さい窓で再生'}
</button>
)}
<p className="status" role="status">
{isActive ? '小さい窓で再生しています' : 'このページの中で再生しています'}
</p>
{error && <p className="error">{error}</p>}
</section>
);
}
ボタンは、動画の長さや大きさ(メタデータ)を読み込むまで押せないようにしています。読み込む前(readyState が0のとき)に requestPictureInPicture() を呼ぶと、次のエラーになりました。
InvalidStateError: Failed to execute 'requestPictureInPicture' on 'HTMLVideoElement': Metadata for the video element are not loaded yet.
ボタン以外の操作で開閉しても、表示がずれない
Chromium の小さい窓には、×ボタンと「Back to tab」(タブに戻る)ボタンがあります。動画の標準のコントロールにある「⋮」のメニューにも「Picture in picture」があります。これらを xdotool で実際に押し、フックの状態がついてくるかを確かめました。
| 操作 | 届いたイベント | ボタンと状態の表示 | 動画 |
|---|---|---|---|
| 「⋮」のメニューの「Picture in picture」 | enterpictureinpicture |
「元の場所に戻す」「小さい窓で再生しています」 | — |
| 小さい窓の×ボタン | leavepictureinpicture |
「小さい窓で再生」「このページの中で再生しています」 | 一時停止した |
| 小さい窓の「Back to tab」ボタン | leavepictureinpicture |
「小さい窓で再生」「このページの中で再生しています」 | 再生が続いた |
どの操作でもイベントが届くので、ボタンの文言と状態の表示はずれませんでした。
1つのページに動画が2本あるときも試しました。1本目を小さい窓に出したまま、2本目の「小さい窓で再生」を押すと、1本目に leavepictureinpicture、2本目に enterpictureinpicture が届きます。検証環境の Chromium では、小さい窓に出せる動画は1本だけで、2本ともボタンの表示が正しく入れ替わりました。
なお、MDN の Picture-in-Picture API の説明には、ページのコードではなくブラウザが動画を小さい窓に移した場合は enterpictureinpicture が起きない、と書かれています。例として、タブの切り替えや、動画のコンテキストメニュー・ブラウザの画面から利用者が選んだ場合が挙がっています。そのときは navigator.mediaSession.setActionHandler('enterpictureinpicture', …) で処理を登録する、とされています。ただし検証環境の Chromium 153 では、上の表のとおり、動画のコントロールの「⋮」メニューから入ったときにもイベントが届きました。
動画のコンポーネントが消えても、小さい窓は残る
上のフックから、クリーンアップ関数の中の if 文(document.exitPictureInPicture() を呼ぶ3行)を抜いた版でも試しました。小さい窓で再生している最中に、親のコンポーネントで動画を消すと、次の画面のようになりました。

ページの中の <video> は0個になりました。それでも document.pictureInPictureElement は、ページから外れた古い <video> を指したままでした。leavepictureinpicture も届きません。消してから3秒たっても、小さい窓の動画の再生位置は 0.40秒から 3.41秒へ進み続けていました。
そのあと「動画を出す」でコンポーネントを出し直しても、新しい <video> とは別の、古い <video> が小さい窓に残っていました。新しい動画の下には「このページの中で再生しています」と出ていて、残っている小さい窓は表示に反映されません。
そこで、フックの useEffect のクリーンアップ関数で、自分の動画が小さい窓にあれば document.exitPictureInPicture() を呼ぶようにしました。React の useEffect の説明にあるとおり、クリーンアップ関数はコンポーネントが DOM から取り除かれたあとにも呼ばれます。この版では、動画を消すと小さい窓が閉じ、document.pictureInPictureElement は null になりました。検証はすべて開発時の <StrictMode> の下で行いました。マウント時にセットアップとクリーンアップが1回余分に走っても、ボタンでの出し入れは崩れませんでした。
ボタンのクリックの中で呼ぶ
requestPictureInPicture() は、ボタンのクリックのような利用者の操作の中で呼びます。ページを開いて何も操作していない状態で、読み込みが終わったときに自動で呼ぶと、次のエラーになりました。
NotAllowedError: Failed to execute 'requestPictureInPicture' on 'HTMLVideoElement': Must be handling a user gesture if there isn't already an element in Picture-in-Picture.
Picture-in-Picture の仕様にある、requestPictureInPicture() の手順でも、まだ小さい窓に何も出ていないときは、利用者の操作の直後(transient activation)でないと NotAllowedError にする、と決まっています。ページを開いたらすぐ小さい窓で再生する、という作りにはできません。
小さい窓に出させたくない動画には disablePictureInPicture
小さい窓に出させたくない動画には、<video> に disablePictureInPicture を付けます。React の JSX でもそのまま書けます。
<video src="/v.webm" controls muted disablePictureInPicture />
Chromium で描画された要素は <video src="/v.webm" controls="" disablepictureinpicture=""> になり、コンソールに警告は出ませんでした。この動画で requestPictureInPicture() を呼ぶと、InvalidStateError になりました。
ただし、これはサイトからブラウザへのお願いです。MDN の disablePictureInPicture の説明によると、Firefox では、利用者の設定でこの指定を無視できます。互換性データでも、Firefox は「小さい窓のボタンは隠れるが、利用者は小さい窓にできる」という部分的な対応とされています。





