Reactで、要素を全画面表示に切り替える方法
要素を全画面で表示するには、ブラウザの Fullscreen API の element.requestFullscreen() を、ボタンのクリックの中で呼びます。全画面かどうかを自分の state で持つのではなく、document.fullscreenElement を useSyncExternalStore で読むようにすると、Esc キーで抜けたときもボタンの表示がずれません。
ここでは、全画面への出入りを1つのボタンで切り替えるフック useFullscreen を作り、グラフの入った枠を全画面にしました。あわせて、うまくいかない3つの書き方(state を自分で持つ、ページを開いたときに全画面にする、document.body にポータルを出す)が、どう失敗するかも確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。Esc キーで抜ける動きは、仮想ディスプレイ(Xvfb)の上で画面ありの Chromium を動かし、xdotool でキーを送って確かめました。Firefox と Safari、スマートフォンのブラウザは試していません。

全画面の状態を useSyncExternalStore で読むフック
全画面に入るのは requestFullscreen()、抜けるのは document.exitFullscreen() です。どちらも Promise を返します。今どの要素が全画面かは document.fullscreenElement で分かり、全画面でなければ null です。入ったときと抜けたときには、fullscreenchange イベントが document まで届きます。
全画面は、Esc キーのように React の外の操作でも終わります。そこで、document.fullscreenElement をブラウザが持つ値として扱い、useSyncExternalStore でブラウザの API を購読する書き方で読みます。
import { useCallback, useState, useSyncExternalStore } from 'react';
// 全画面の出入りは Esc キーなど React の外でも起きるので、
// document.fullscreenElement を外部のストアとして読む
function subscribe(onChange: () => void) {
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}
const getSnapshot = () => document.fullscreenElement;
const getServerSnapshot = () => null;
export function useFullscreen<T extends HTMLElement>() {
// ref.current をレンダー中に読まないよう、要素は state に持つ
const [element, setElement] = useState<T | null>(null);
const fullscreenElement = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const isFullscreen = element !== null && fullscreenElement === element;
const enter = useCallback(async () => {
if (!element) return;
await element.requestFullscreen();
}, [element]);
const exit = useCallback(async () => {
if (document.fullscreenElement) await document.exitFullscreen();
}, []);
const toggle = useCallback(
() => (isFullscreen ? exit() : enter()),
[isFullscreen, enter, exit],
);
return { ref: setElement, isFullscreen, fullscreenElement, enter, exit, toggle };
}
subscribe と getSnapshot はコンポーネントの外に置いています。レンダーのたびに違う subscribe を渡すと、React が購読し直すためです。getServerSnapshot は、サーバーでレンダーするときに getSnapshot の代わりに呼ばれる関数です。サーバーには全画面がないので null を返すようにしました。
「この要素が全画面かどうか」は、fullscreenElement と自分の要素を比べて決めます。要素を useRef で持つと、比べるためにレンダー中に ref.current を読むことになります。公式リファレンスでは、レンダー中に ref.current を読み書きしないよう書かれています。そこで、ref には state の set 関数を渡し、要素を state に持たせました。
ボタンで全画面に切り替える
コンポーネントでは、フックが返す ref を全画面にしたい要素に付け、ボタンのクリックで toggle を呼びます。requestFullscreen() が拒否されると Promise が reject されるので、try / catch で受けて画面に出します。次のコードは、検証に使ったコンポーネントから、全画面の切り替えに関わる部分を抜き出したものです。
export function App() {
const { ref, isFullscreen, fullscreenElement, toggle } = useFullscreen<HTMLDivElement>();
const [error, setError] = useState('');
async function handleClick() {
setError('');
try {
await toggle();
} catch (e) {
// requestFullscreen() が拒否されると、Promise が reject される
setError(e instanceof Error ? `${e.name}: ${e.message}` : String(e));
}
}
return (
<main>
<div ref={ref} className="viewer">
{/* グラフなど、全画面で見せたい中身 */}
<button type="button" onClick={handleClick}>
{isFullscreen ? '全画面を終了' : '全画面で表示'}
</button>
</div>
{error && <p role="alert">{error}</p>}
</main>
);
}
全画面のときだけの見た目は、CSS の :fullscreen 疑似クラスで書けます。全画面の要素の後ろに敷かれる面は、::backdrop で色を変えられます。
.viewer:fullscreen { display: flex; flex-direction: column; justify-content: center; padding: 24px; border: 0; border-radius: 0; }
.viewer:fullscreen .picture { flex: 1; }
.viewer::backdrop { background: #1f2a30; }
Playwright でボタンを押した結果です。全画面に入ると、枠の大きさが画面と同じ 1280×720 になり、ボタンの文字も変わりました。ログの [ページ] の行は、確かめるために別に付けたリスナーが、fullscreenchange と keydown のたびに書き出したものです。
最初: 通常 / fullscreenElement: null
[ページ] fullscreenchange fullscreenElement=viewer
「全画面で表示」を押した: 全画面 / fullscreenElement: div.viewer / ボタン: 全画面を終了
全画面の要素の大きさ: 1280x720(画面 1280x720)
[ページ] fullscreenchange fullscreenElement=null
「全画面を終了」を押した: 通常 / fullscreenElement: null
Esc キーで抜けたときも、ボタンの表示が合う
全画面のときに Esc キーを押すと、ブラウザが全画面を終わらせます。このとき fullscreenchange が起きるので、useFullscreen を使ったボタンは「全画面で表示」に戻りました。
「全画面で表示」を押した: 全画面
[ページ] keydown key=Escape
Playwright で Escape を送った: 全画面 / fullscreenElement: div.viewer
[ページ] fullscreenchange fullscreenElement=null
Esc キーを押した: 通常 / fullscreenElement: null / ボタン: 全画面で表示
上の出力では、比べるために、先に Playwright のキー入力で Escape を送っています。こちらはページに keydown として届いただけで、全画面は終わりませんでした。そのあと xdotool で X サーバーを通した実際のキー入力として Esc を送ると、全画面が終わりました。このとき、ページの keydown のリスナーには何も届いていません。検証環境の Chromium では、Esc キーで抜けたことを keydown で知ることはできず、fullscreenchange を見る必要があります。
自分で state を切り替えるだけの書き方
requestFullscreen() のあとに setIsFullscreen(true) とするだけの書き方では、Esc キーで抜けても state が変わりません。
async function handleClick() {
if (isFullscreen) {
await document.exitFullscreen();
setIsFullscreen(false);
} else {
await ref.current?.requestFullscreen();
setIsFullscreen(true);
}
}
Esc キーで抜けたあとも、表示は「全画面」のままでした。そのままボタンを押すと、全画面ではないのに exitFullscreen() を呼ぶことになり、エラーになりました。エラーのあとも、state は「全画面」のままでした。
「全画面で表示」を押した: 全画面 / fullscreenElement: div.viewer
[ページ] fullscreenchange fullscreenElement=null
Esc キーを押した: 全画面 / fullscreenElement: null / ボタン: 全画面を終了
「全画面を終了」を押した: 全画面 / 捕まえていないエラー: TypeError: Failed to execute 'exitFullscreen' on 'Document': Document not active
ボタンを押したときにしか全画面にできない
requestFullscreen() を使えるのは、ユーザーがクリックなどの操作をした直後だけです。MDN の User activation のページには、この「直後」の状態(transient activation)は時間がたつと切れること、requestFullscreen() がこれを必要とする API の1つであることが書かれています。
ページを開いたときに useEffect で全画面にしようとすると、拒否されました。
useEffect(() => {
ref.current?.requestFullscreen().then(
() => setResult('全画面になった'),
(e: Error) => setResult(`${e.name}: ${e.message}`),
);
}, []);
[ページ] Failed to execute 'requestFullscreen' on 'Element': API can only be initiated by a user gesture.
結果: TypeError: Permissions check failed / fullscreenElement: null
クリックのあとに待ち時間をはさんでから requestFullscreen() を呼ぶと、どうなるかも試しました。データを読み込んでから全画面にするような流れを想定しています。検証環境の Chromium では、1秒後と4秒後は全画面になり、6秒後は同じエラーで拒否されました。
1000ミリ秒後: 全画面になった
4000ミリ秒後: 全画面になった
[ページ] Failed to execute 'requestFullscreen' on 'Element': API can only be initiated by a user gesture.
6000ミリ秒後: TypeError: Permissions check failed
何秒で切れるかは、ほかのブラウザでは試していません。クリックの処理の中では、時間のかかる処理より先に requestFullscreen() を呼んでおくほうが確実です。
MDN のブラウザー互換性の表では、iOS の Safari の requestFullscreen() は iPad だけで使え、iPhone では使えないとされています(iPhone では試していません)。全画面にできなくても困らないように作り、使えるかどうかは document.fullscreenEnabled で確かめます。
全画面の中では、body に出したポータルが見えない
全画面の要素は、ページのほかの部分より上の「最上位レイヤー」に置かれます。そのため、全画面の要素の外にあるものは、画面に出ません。
React でトーストやモーダルを createPortal で document.body に出していると、全画面の間はそれが見えなくなります。全画面の枠の中から「お知らせを出す」を押し、お知らせの真ん中の位置に何があるかを document.elementFromPoint で調べました。
createPortal(<div role="status" className="toast">保存しました</div>, document.body)
お知らせの親: body / お知らせの位置にある要素: viewer
お知らせは body の中にできていますが、その位置にあるのは全画面の枠(viewer)でした。お知らせは枠の下に隠れています。
createPortal の2つ目の引数には、出す先の DOM ノードを渡します。useFullscreen は fullscreenElement も返すので、全画面の間は全画面の要素の中に出すようにしました。
{toast &&
createPortal(
<div role="status" className="toast">保存しました</div>,
// 全画面の間は、全画面の要素の中に出す
fullscreenElement ?? document.body,
)}
今度は、お知らせの位置にあるのがお知らせ自身になり、画面に表示されました。Esc キーで抜けると、お知らせは body の中に移りました。
お知らせの親: div.viewer / お知らせの位置にある要素: toast
[ページ] fullscreenchange fullscreenElement=null
Esc キーで抜けたあとのお知らせの親: body
公式リファレンスによると、出す先の DOM ノードが変わると、ポータルの中身は作り直されます。この書き方では、全画面の出入りのたびにお知らせが作り直されます。中に入力欄のような、入力途中の値を持つものを出す場合は、この点に気をつけてください。





