Reactで、画面から外れた動画を自動で一時停止する方法
スクロールして動画が画面から外れたら一時停止するには、<video> 要素を IntersectionObserver で見張り、見えている割合がしきい値を下回ったときに pause() を呼びます。戻ってきたときに続きを再生するのは、フックが止めた動画だけにします。こうすると、ユーザーが一時停止ボタンで止めた動画を、スクロールのたびに勝手に再生してしまうことがありません。
ここでは、そのためのカスタムフック useAutoPause を作りました。あわせて、見えたら必ず play() する書き方がどう困るか、play() の Promise を受けないとどんなエラーが出るかも確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。動画は ffmpeg のテスト用の映像と音で作った 30 秒の WebM です。Firefox と Safari、スマートフォンのブラウザは試していません。

画面から外れたら止めるフック
要素が画面にどれだけ入っているかは、IntersectionObserver で知ることができます。オプションの threshold に割合を渡すと、見えている割合がその値をまたいだときに、コールバックが呼ばれます(MDN の Thresholds の説明)。コールバックに渡される entry の intersectionRatio が、そのときに見えている割合です。
IntersectionObserver は React の外にあるブラウザの機能なので、useEffect の中で作り、クリーンアップで止めます。公式リファレンスにも、useEffect で IntersectionObserver を使って要素が見えているかを調べる例があります。
import { useEffect, type RefObject } from 'react';
// 動画が画面から外れたら一時停止し、戻ってきたら再生を続ける。
// 続きを再生するのは、このフックが止めた動画だけ(ユーザーが止めた動画は止めたまま)
export function useAutoPause(
ref: RefObject<HTMLVideoElement | null>,
threshold = 0.5,
) {
useEffect(() => {
const video = ref.current;
if (!video) return;
let pausedByObserver = false;
const observer = new IntersectionObserver(
([entry]) => {
// threshold の割合以上が画面に入っているか
const visible = entry.intersectionRatio >= threshold;
if (!visible && !video.paused) {
video.pause();
pausedByObserver = true;
} else if (visible && pausedByObserver) {
pausedByObserver = false;
// play() は Promise を返し、拒否されることがある
video.play().catch(() => {});
}
},
{ threshold },
);
observer.observe(video);
// ユーザーが自分で再生し直したら、「フックが止めた」を取り消す
const onPlay = () => { pausedByObserver = false; };
video.addEventListener('play', onPlay);
return () => {
observer.disconnect();
video.removeEventListener('play', onPlay);
};
}, [ref, threshold]);
}
「フックが止めたかどうか」は pausedByObserver という変数に持たせています。画面に戻ったときに続きを再生するのは、この変数が true のときだけです。画面の表示には使わない値なので、state にはせず、Effect の中の変数にしました。
動画が止まっているかどうかは、video.paused で分かります。フックが止めたあと、画面の外にある間に、別の操作で再生され、また止められることもあります。そのときに「フックが止めた」が残っていると、画面に戻ったときに再生してしまいます。そこで、play イベントが起きたら pausedByObserver を false に戻すようにしました。
使う側では、<video> に付けた ref をフックに渡します。
import { useRef, type ComponentProps } from 'react';
import { useAutoPause } from './useAutoPause';
export function AutoPauseVideo(props: ComponentProps<'video'>) {
const ref = useRef<HTMLVideoElement>(null);
useAutoPause(ref);
return <video ref={ref} controls playsInline {...props} />;
}
<AutoPauseVideo src="/sample.webm" width={320} height={180} />
スクロールで出し入れしたときの動き
Playwright で動画をクリックして再生し、スクロールで少しずつ画面の外へ出しました。ログの [ページ] の行は、確かめるために別に付けた IntersectionObserver(しきい値は同じ 0.5)のコールバックが、呼ばれたときに書き出したものです。observe() を呼んだ直後にも1回呼ばれるので、その行は省いています。
動画をクリックして再生: 見えている割合 1.00 / 再生中 / 0.6秒
動画の60%が見える位置までスクロール: 見えている割合 0.60 / 再生中 / 1.2秒
[ページ] isIntersecting=false intersectionRatio=0.40
動画の40%が見える位置までスクロール: 見えている割合 0.40 / 一時停止 / 1.3秒
動画が見えなくなる位置までスクロール: 見えている割合 0.00 / 一時停止 / 1.3秒
1秒待った: 見えている割合 0.00 / 一時停止 / 1.3秒
[ページ] isIntersecting=true intersectionRatio=1.00
画面に戻した(1秒後): 見えている割合 1.00 / 再生中 / 2.3秒
60% が見えている間は、しきい値の 50% をまたいでいないので、コールバックは呼ばれず、再生が続きました。40% まで出したところでコールバックが呼ばれ、動画が止まりました。画面の外にある間は再生位置が 1.3 秒のまま進まず、画面に戻すと続きから再生されました。
続けて、動画をクリックして自分で一時停止してから、画面の外へ出して戻しました。
動画をクリックして一時停止: 見えている割合 1.00 / 一時停止 / 2.3秒
[ページ] isIntersecting=false intersectionRatio=0.00
[ページ] isIntersecting=true intersectionRatio=1.00
画面から出して、戻した(1秒後): 見えている割合 1.00 / 一時停止 / 2.3秒
画面に戻ったときもコールバックは呼ばれていますが、フックが止めた動画ではないので、止めたままになりました。
最後に、もう一度再生して画面の外へ出し、フックに止めさせました。そのまま画面の外で、ページのスクリプトから play() と pause() を呼んでから、画面に戻しています。キーボードなどで再生し直して、また止めた場合の代わりです。
[ページ] isIntersecting=false intersectionRatio=0.00
もう一度クリックで再生し、画面の外へ: 見えている割合 0.00 / 一時停止 / 2.5秒
画面の外で play() と pause() を呼んだ: 見えている割合 0.00 / 一時停止 / 3.0秒
[ページ] isIntersecting=true intersectionRatio=1.00
画面に戻した(1秒後): 見えている割合 1.00 / 一時停止 / 3.0秒
play イベントで pausedByObserver が false に戻っているので、画面に戻しても止めたままでした。
MDN の Thresholds の説明には、isIntersecting が true なら、要素の少なくとも一部が重なっていると書かれています。一方、検証環境の Chromium では、しきい値を 0.5 にすると、40% が見えている状態でも isIntersecting は false でした。このフックでは isIntersecting を使わず、intersectionRatio としきい値を比べて判断しています。
見えたら必ず play() する書き方では、止めた動画が動き出す
「見えていれば play()、見えなければ pause()」とだけ書くと、ユーザーが止めた動画まで再生してしまいます。
const observer = new IntersectionObserver(
([entry]) => {
if (entry.intersectionRatio >= 0.5) video.play();
else video.pause();
},
{ threshold: 0.5 },
);
この書き方で、動画を画面に入れ、クリックで一時停止してから、画面の外へ出して戻しました。
動画を画面に入れた(クリックはしていない。1秒後): 見えている割合 1.00 / 再生中 / 0.9秒
動画をクリックして一時停止: 見えている割合 1.00 / 一時停止 / 1.0秒
[ページ] isIntersecting=false intersectionRatio=0.00
[ページ] isIntersecting=true intersectionRatio=1.00
画面から出して、戻した(1秒後): 見えている割合 1.00 / 再生中 / 2.0秒
自分で止めたはずの動画が、スクロールして戻しただけで再生されました。また、クリックしていないのに、画面に入っただけで再生が始まっています。止めた理由を覚えておかないと、こうした区別ができません。
play() の Promise は catch で受ける
play() は Promise を返し、再生を始められなかったときは reject されます。検証環境の Chromium では、動画の読み込みが終わる前に pause() を呼ぶと、待っていた play() が AbortError で中断されました。
これを確かめるため、Playwright で動画の読み込みを3秒遅らせ、preload="none" の動画を画面に入れて、300ミリ秒後に外へ出しました。見えたら必ず play() する上の書き方では、catch されていないエラー(Uncaught (in promise))が出ました。ログの「捕まえていないエラー」は、Playwright の pageerror イベントで受けたものです。
[捕まえていないエラー] AbortError: The play() request was interrupted by a call to pause(). https://goo.gl/LdLk22
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止
useAutoPause でも同じ流れを試しました。フックが続きを再生するのは自分で止めた動画だけなので、先に再生を始めて画面の外へ出し、フックに止めさせてから、画面に入れて 300ミリ秒後に出しています。
再生を始めて、画面から出した: イベント play → pause / 一時停止
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止
画面に入れたときに play イベントが起き、再生が始まる前に pause が起きています。それでも、catch されていないエラーは出ませんでした。
フックの play() が本当に中断されたのかも確かめました。確かめる側で HTMLMediaElement.prototype.play を差し替え、返された Promise の結果を書き出しています(差し替えた関数の中で結果を受けるので、この実行では、catch されていないエラーが出るかどうかは確かめられません)。
[ページ] play() の結果: AbortError
再生を始めて、画面から出した: イベント play → pause / 一時停止
[ページ] play() の結果: AbortError
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止
1つ目の AbortError は、確かめる側で再生を始めたときの play() が、フックの pause() で中断されたものです。2つ目が、画面に戻ったときにフックが呼んだ play() です。フックの play() も中断されていましたが、前の実行のとおり、catch で受けているので、catch されていないエラーにはなりません。
ブラウザには、音の出る動画の自動再生を止める仕組みがあります。MDN の自動再生のガイドによると、ユーザーがまだページを操作していないと、スクリプトから呼んだ play() は拒否されることがあり、そのときは NotAllowedError で reject されます(play() のページ)。検証環境の Playwright の Chromium では、起動オプションに --autoplay-policy=document-user-activation-required を付けても、クリックの前の play() が成功し、この制限は再現できませんでした。同じガイドには、音を消した(muted の)動画なら自動再生が許されることと、play() が拒否されたときに再生ボタンを出す例が載っています。





