Reactで、画像をクリックして大きく表示するライトボックスを作る方法
一覧の小さな画像を <button> で包み、押したら <dialog> を showModal() で開いて大きな画像を出せば、ライトボックスになります。Lightbox が持つ state は「何枚目を開いているか」の番号1つだけです。Esc キーで閉じる、後ろの画面を操作できなくする、といった処理はブラウザに任せられます。
自分で書く必要があるのは、ライトボックスに特有の部分です。大きな画像を画面に収める、矢印キーで前後に送る、画像が替わる間に前の画像を残さない、閉じたあとに最後に見ていた画像のサムネイルへフォーカスを戻す、後ろのページをスクロールさせない、の5つです。あわせて、前後の画像を先に読み込んで、送ったときの待ち時間を減らします。ここでは、これらを Lightbox という部品にまとめ、Chromium で動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。画像は写真ではなく、Pillow で描いた番号入りの PNG(横長 1600×1067 を4枚、縦長 800×1200 を1枚)です。読み込みの遅さは、検証用のサーバーで大きい画像の応答を 1 秒遅らせて再現しました。実際の回線、タッチ操作(指でのスワイプなど)、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

開いている画像の番号を state に持ち、dialog で開く
Lightbox は、画像の一覧 photos を受け取ります。1枚ごとに、一覧に出す小さい画像(thumb)と、拡大して見せる画像(full)を分けて持たせています。
import { useEffect, useRef, useState } from 'react';
import type { KeyboardEvent, MouseEvent } from 'react';
export type Photo = {
thumb: string; // 一覧に出す小さい画像
full: string; // 拡大して見せる画像
alt: string;
};
export function Lightbox({ photos }: { photos: Photo[] }) {
// 開いている画像の番号。閉じているときは null
const [index, setIndex] = useState<number | null>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isOpen = index !== null;
// state に合わせて、dialog をモーダルとして開く・閉じる
useEffect(() => {
const dialog = dialogRef.current!;
if (isOpen && !dialog.open) dialog.showModal();
if (!isOpen && dialog.open) dialog.close();
}, [isOpen]);
// いま開いている画像の、前と次の画像を先に読み込んでおく
useEffect(() => {
if (index === null) return;
for (const step of [-1, 1]) {
const img = new Image();
img.src = photos[(index + step + photos.length) % photos.length].full;
}
}, [index, photos]);
// 端まで行ったら反対の端へ回る
function move(step: number) {
setIndex((i) => (i === null ? i : (i + step + photos.length) % photos.length));
}
// Esc キー、閉じるボタン、画像の外のクリックのどれで閉じても、ここを通る
function handleClose() {
// 最後に見ていた画像のサムネイルへフォーカスを移す
if (index !== null) thumbRefs.current[index]?.focus();
setIndex(null);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') move(-1);
if (e.key === 'ArrowRight') move(1);
}
// 画像やボタンではなく、dialog 自身(画像のまわりの暗い所)を押したら閉じる
function handleClick(e: MouseEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget) e.currentTarget.close();
}
const photo = index === null ? null : photos[index];
return (
<>
<ul className="thumbs">
{photos.map((p, i) => (
<li key={p.full}>
<button
type="button"
ref={(el) => {
thumbRefs.current[i] = el;
}}
onClick={() => setIndex(i)}
>
<img src={p.thumb} alt={p.alt} width={240} height={160} />
</button>
</li>
))}
</ul>
<dialog
ref={dialogRef}
className="lightbox"
aria-label="画像の拡大表示"
onClose={handleClose}
onKeyDown={handleKeyDown}
onClick={handleClick}
>
{photo && (
<>
{/* key を変えて、画像が替わるたびに作り直す */}
<FullImage key={photo.full} photo={photo} />
<p className="lightbox-count">
{index! + 1} / {photos.length}
</p>
<button type="button" className="lightbox-prev" aria-label="前の画像" onClick={() => move(-1)}>
‹
</button>
<button type="button" className="lightbox-next" aria-label="次の画像" onClick={() => move(1)}>
›
</button>
<button type="button" className="lightbox-close" aria-label="閉じる" onClick={() => dialogRef.current!.close()}>
×
</button>
</>
)}
</dialog>
</>
);
}
// 読み込みが終わるまでは「読み込み中…」を出し、画像は隠しておく
function FullImage({ photo }: { photo: Photo }) {
const [loaded, setLoaded] = useState(false);
return (
<>
{!loaded && <p className="lightbox-loading">読み込み中…</p>}
<img
className="lightbox-image"
src={photo.full}
alt={photo.alt}
onLoad={() => setLoaded(true)}
style={{ visibility: loaded ? 'visible' : 'hidden' }}
/>
</>
);
}
使う側は、画像の一覧を渡すだけです。
import { Lightbox } from './Lightbox';
import type { Photo } from './Lightbox';
const photos: Photo[] = [1, 2, 3, 4, 5].map((n) => ({
thumb: `thumb/${n}.png`,
full: `large/${n}.png`,
alt: `番号 ${n} の画像`,
}));
export function App() {
return (
<main>
<h1>画像の一覧</h1>
<Lightbox photos={photos} />
</main>
);
}
index が null なら閉じていて、数字なら、その番号の画像を開いています。useEffect の中で、この state に合わせて showModal() と close() を呼びます。
showModal() で開いた <dialog> は、外側の画面が操作できなくなり、Esc キーで閉じられます。開いたときは、中で最初にフォーカスできる要素にフォーカスが移ります(MDN の dialog 要素のページ)。検証では、1枚目のサムネイルを押すと「1 / 5」が開き、dialog は :modal に当てはまりました。フォーカスは、中で最初に置いた「前の画像」ボタンに移っていました。
サムネイルを <button> にしているのは、キーボードでフォーカスして Enter キーで開けるようにするためです。ボタンの名前には、中の <img> の alt が使われます。テストでも getByRole('button', { name: '番号 1 の画像' }) でサムネイルを見つけられました。
閉じ方は、Esc キー、「閉じる」ボタン、画像のまわりの暗い部分のクリックの3つです。ボタンとクリックでは dialog.close() を呼ぶだけにして、state を null に戻すのは onClose の handleClose に任せています。dialog の close イベントは、閉じ方によらず閉じたときに届くので、どの閉じ方でも state がずれません。
画像の上をクリック: 開いている / 2 / 5 / alt「番号 2 の画像」
画像の外(下の端)をクリック: 閉じている
「閉じる」ボタン: 閉じている
Esc キー: 閉じている
閉じたあと、もう一度開く: 開いている / 2 / 5 / alt「番号 2 の画像」
暗い部分のクリックで閉じるのに使っているのは、e.target === e.currentTarget の判定です。dialog を画面いっぱいに広げているので、画像のまわりの暗い部分は dialog 自身です。画像やボタンを押したときは e.target がその要素になるので、閉じません。
大きい画像を、縦横の比を保って画面に収める
ブラウザの既定のスタイルでは、モーダルの dialog に枠線と余白が付き、幅と高さにも上限があります(HTML の仕様の Rendering の章にある dialog と dialog:modal の指定)。ライトボックスでは画面いっぱいに使いたいので、これらを外しています。
/* dialog を画面いっぱいに広げ、まん中に画像を置く */
.lightbox {
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: rgb(0 0 0 / 0.85);
color: #fff;
}
.lightbox[open] { display: grid; place-items: center; }
/* 画面より大きい画像は、縦横の比を保ったまま画面に収める */
.lightbox-image {
grid-area: 1 / 1;
max-width: calc(100vw - 32px);
max-height: calc(100dvh - 96px);
}
.lightbox-loading { grid-area: 1 / 1; margin: 0; }
画像には width と height を指定せず、max-width と max-height だけを付けています。こうすると、画面より大きい画像は、縦横の比を保ったまま、どちらかの上限に当たる大きさまで縮みます。max-height から引いた 96px は、上の「閉じる」ボタンと下の「1 / 5」の表示のための空きです。画面の大きさを変えて、横長の1枚目と縦長の3枚目を開いた結果です。
画面 800×600 / 番号 1(元の大きさ 1600×1067): 表示 755.8×504.0、縦横の比 元 1.500 → 表示 1.500、左上 (22.1, 48.0)
画面 800×600 / 番号 3(元の大きさ 800×1200): 表示 336.0×504.0、縦横の比 元 0.667 → 表示 0.667、左上 (232.0, 48.0)
画面 360×640 / 番号 1(元の大きさ 1600×1067): 表示 328.0×218.7、縦横の比 元 1.500 → 表示 1.499、左上 (16.0, 210.6)
画面 360×640 / 番号 3(元の大きさ 800×1200): 表示 328.0×492.0、縦横の比 元 0.667 → 表示 0.667、左上 (16.0, 74.0)
800×600 の画面では、どちらの画像も高さの上限(600 − 96 = 504px)で止まり、幅は比に合わせて決まりました。360×640 の画面では、幅の上限(360 − 32 = 328px)で止まっています。縦横の比はどの場合も元の画像と同じで(元の画像の比は 1600 ÷ 1067 = 1.4995 で、1.500 と 1.499 は小数第3位で丸めた結果が分かれただけです)、画像は画面のまん中に置かれました。
矢印キーとボタンで、前後の画像へ送る
move は、番号に 1 を足すか引くかして、画像の枚数で割った余りを取ります。最後の画像の次は最初の画像に、最初の画像の前は最後の画像に回ります。
矢印キーは、dialog の onKeyDown で受けています。dialog の中のどこにフォーカスがあっても、キーのイベントは dialog まで伝わってくるので、ここ1か所で受ければ足ります。
→ キーを2回: 開いている / 3 / 5 / alt「番号 3 の画像」
← キーを3回: 開いている / 5 / 5 / alt「番号 5 の画像」
「次の画像」ボタン: 開いている / 1 / 5 / alt「番号 1 の画像」
「前の画像」ボタン: 開いている / 5 / 5 / alt「番号 5 の画像」
1枚目から → キーを2回で3枚目、そこから ← キーを3回で、1枚目を越えて5枚目に回りました。5枚目で「次の画像」を押すと1枚目に戻ります。
画像が替わるとき、前の画像を残さない
大きい画像は、FullImage という小さな部品に分け、key={photo.full} を付けて描いています。この key がないと、画像を送ったときに、新しい画像が届くまで前の画像が表示されたままになります。
比べるために、FullImage の部分を、key を付けない <img> に置き換えたページを作りました。
< <FullImage key={photo.full} photo={photo} />
---
> <img className="lightbox-image" src={photo.full} alt={photo.alt} />
大きい画像の応答を 1 秒遅らせ、1枚目を表示した状態で → キーを押しました。300ms 後に、画像の左寄りの1画素の色を調べた結果です。1枚目の背景は rgb(64, 160, 220)(青)、2枚目は rgb(240, 170, 60)(オレンジ)です。この比較では、次の節で説明する先読みを止めています。key ありで rgb(35, 35, 35) になっているのは、画像を隠しているので、調べた画素が暗い背景の色だったためです。
key あり(FullImage): → の 300ms 後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 画像の色 rgb(35, 35, 35) / 読み込み中の表示 あり
key あり(FullImage): 読み込み後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 画像の色 rgb(240, 170, 60) / 読み込み中の表示 なし
key なし(img の src だけ替える): → の 300ms 後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 画像の色 rgb(64, 160, 220) / 読み込み中の表示 なし
key なし(img の src だけ替える): 読み込み後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 画像の色 rgb(240, 170, 60) / 読み込み中の表示 なし
key がない場合は、表示が「2 / 5」、alt も「番号 2 の画像」に替わっているのに、画面には1枚目の青い画像が残っていました。React は同じ <img> 要素を使い回して src だけを書き換え、ブラウザは新しい画像が届くまで前の画像を描き続けるためです。

key を変えると、React はその部品を別のものとして作り直し、中の state も初めからになります(React の公式ドキュメントにある、key で state をリセットする説明)。そのため FullImage の loaded は画像が替わるたびに false に戻り、届くまでは「読み込み中…」が出ます。同じページにある、次の画像を読み込んでいる間に前の画像を消す練習問題も、<img> に key を付けて解いています。そこでは、画像が読み込まれるまでの短い間ちらつくので、すべての画像に付けるものではない、とも書かれています。ライトボックスでは、番号と画像が食い違うほうが困るので、key を付けました。
前後の画像を先に読み込んでおく
key を付けると前の画像は残りませんが、送るたびに「読み込み中…」が出ます。そこで、開いている画像の前と次の画像を、2つ目の useEffect で先に読み込んでいます。new Image() は document.createElement('img') と同じ働きで、ページに置かなくても src を入れた時点で読み込みが始まります。
比べるために、new Image() で作った要素に src を入れても何も読み込まないように置き換えたページも用意しました。大きい画像の応答は 1 秒遅らせています。出力の「1.2 秒待つまで」は、1枚目の読み込みが終わってから、さらに 1.2 秒待つまでのことです。
先読みあり: 番号 1 を開いて 1.2 秒待つまでに要求された画像: large/1.png, large/5.png, large/2.png
先読みあり: → の 50ms 後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 読み込み中の表示 なし
先読みなし(new Image() を止めたページ): 番号 1 を開いて 1.2 秒待つまでに要求された画像: large/1.png
先読みなし(new Image() を止めたページ): → の 50ms 後 / 開いている / 2 / 5 / alt「番号 2 の画像」 / 読み込み中の表示 あり
先読みありでは、1枚目を開いたときに、前の5枚目と次の2枚目も要求されていました。2枚目へ送った 50ms 後には、もう「読み込み中…」は出ていません。先読みなしでは、要求されたのは1枚目だけで、送ったあとに「読み込み中…」が出ました。
閉じたら、最後に見ていた画像のサムネイルへフォーカスを戻す
showModal() で開いた dialog を閉じると、ブラウザは、開く前にフォーカスがあった要素へフォーカスを戻します。ライトボックスの場合、それは最初に押したサムネイルです。途中で画像を送っていると、見ていた画像とフォーカスの位置がずれます。
そこで handleClose の中で、最後に見ていた画像のサムネイルの focus() を呼んでいます。ブラウザが戻したフォーカスと、handleClose のあとのフォーカスを比べました。ブラウザが戻した位置は、React の onClose より先に動くリスナーを document に置いて記録しています。
番号 1 のサムネイルで Enter: 開いている / 1 / 5 / alt「番号 1 の画像」
→ キーを2回: 開いている / 3 / 5 / alt「番号 3 の画像」
Esc で閉じた直後(React の onClose の前)のフォーカス: サムネイル「番号 1 の画像」
onClose のあとのフォーカス: サムネイル「番号 3 の画像」
そのまま Enter: 開いている / 3 / 5 / alt「番号 3 の画像」
ブラウザが戻したのは、開くときに押した1枚目のサムネイルでした。handleClose のあとは、最後に見ていた3枚目のサムネイルにフォーカスがあり、そのまま Enter キーを押すと3枚目が開きます。キーボードで操作している人は、閉じたあとも、見ていた画像の場所から続けられます。
close イベントは、閉じる処理が終わったあとに届きます。ブラウザがフォーカスを戻したあとで focus() を呼ぶことになるので、上書きされずに済みます。
開いている間は、後ろのページをスクロールさせない
モーダルの dialog を開いていても、後ろのページはマウスのホイールでスクロールできました。画像を見ている間に後ろのページが動かないように、CSS の :has() を使い、ライトボックスが開いている間だけ、html 要素に overflow: hidden を付けています。
/* 開いている間は、後ろのページをスクロールさせない */
html:has(.lightbox[open]) { overflow: hidden; }
縦に長くしたページ(画面 360×640)で、少しスクロールしてから5枚目を開き、開いたままホイールを回しました。比べたのは、この指定を打ち消したページです。
html:has(.lightbox[open]) { overflow: hidden } あり: 開いた直後の scrollY 286 → 開いたままホイールで 500px 分スクロールしたあと 286
なし: 開いた直後の scrollY 286 → 開いたままホイールで 500px 分スクロールしたあと 786
指定がないと、後ろのページが 500px 動きました。指定があると、位置は 286px のままでした。React の側でクラスを付け外しする処理は要らず、dialog の open 属性に合わせて CSS だけで切り替わります。





