Reactで、画像をクリックして大きく表示するライトボックスを作る方法

​​React​CSS,dialog,Playwright,TypeScript,アクセシビリティ

一覧の小さな画像を <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、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

2枚目のサムネイルをクリックすると画面いっぱいに 2 の画像が開き、→ キーを2回押すと 3、4 と替わって下の「2 / 5」の数字も変わり、Esc キーで閉じると 4 のサムネイルに水色の枠が付く
サムネイルのクリック、→ キー2回、Esc キーでの動き。変わった番号だけピンクにしている(カーソル、押したキー、操作の説明は GIF 用に重ねたもの)

開いている画像の番号を state に持ち、dialog で開く

Lightbox は、画像の一覧 photos を受け取ります。1枚ごとに、一覧に出す小さい画像(thumb)と、拡大して見せる画像(full)を分けて持たせています。

tsx
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' }}
      />
    </>
  );
}

使う側は、画像の一覧を渡すだけです。

tsx
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 がずれません。

text
画像の上をクリック: 開いている / 2 / 5 / alt「番号 2 の画像」
画像の外(下の端)をクリック: 閉じている
「閉じる」ボタン: 閉じている
Esc キー: 閉じている
閉じたあと、もう一度開く: 開いている / 2 / 5 / alt「番号 2 の画像」

暗い部分のクリックで閉じるのに使っているのは、e.target === e.currentTarget の判定です。dialog を画面いっぱいに広げているので、画像のまわりの暗い部分は dialog 自身です。画像やボタンを押したときは e.target がその要素になるので、閉じません。

大きい画像を、縦横の比を保って画面に収める

ブラウザの既定のスタイルでは、モーダルの dialog に枠線と余白が付き、幅と高さにも上限があります(HTML の仕様の Rendering の章にある dialog と dialog:modal の指定)。ライトボックスでは画面いっぱいに使いたいので、これらを外しています。

css
/* 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枚目を開いた結果です。

text
画面 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か所で受ければ足ります。

text
→ キーを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> に置き換えたページを作りました。

text
<             <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) になっているのは、画像を隠しているので、調べた画素が暗い背景の色だったためです。

text
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 ありの画面で、暗い背景に「読み込み中…」と「2 / 5」が出ている。下は key なしの画面で、「2 / 5」と出ているのに 1 の青い画像が表示されている
→ キーを押して 300ms 後の画面。key がないと、番号は 2 なのに 1枚目の画像が残る

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 秒待つまでのことです。

text
先読みあり: 番号 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 に置いて記録しています。

text
番号 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 を付けています。

css
/* 開いている間は、後ろのページをスクロールさせない */
html:has(.lightbox[open]) { overflow: hidden; }

縦に長くしたページ(画面 360×640)で、少しスクロールしてから5枚目を開き、開いたままホイールを回しました。比べたのは、この指定を打ち消したページです。

text
html:has(.lightbox[open]) { overflow: hidden } あり: 開いた直後の scrollY 286 → 開いたままホイールで 500px 分スクロールしたあと 286
なし: 開いた直後の scrollY 286 → 開いたままホイールで 500px 分スクロールしたあと 786

指定がないと、後ろのページが 500px 動きました。指定があると、位置は 286px のままでした。React の側でクラスを付け外しする処理は要らず、dialog の open 属性に合わせて CSS だけで切り替わります。

​Posted by 86