画像ギャラリーの難所は遅延読み込みとライトボックス: 自作の勘所
画像ギャラリーで詰まるのは並べ方ではなく遅延読み込みとライトボックス。loading=lazyとIntersectionObserverの使い分け、srcset、拡大表示とキーボード送りまで実装で解説。
「画像ギャラリーなんて、gridで並べて終わりでしょ」
僕も最初はそう思っていました。実際、並べるだけなら15分で終わります。問題はそこから先です。スマホで開いたら本文が出る前に画像40枚をぜんぶ読みに行ってクルクルが止まらない。拡大表示(ライトボックス)を付けたら、開いたまま後ろの一覧がスクロールできてしまう。Escで閉じない。閉じた後、フォーカスがページの先頭に飛ぶ。
つまりギャラリーの本体は「並べ方」じゃなくて、**いつ画像を読むか(遅延読み込み)と開いた拡大表示をどう閉じるか(ライトボックス)**なんです。この2つを外すと、見た目がどんなに整っていても公開後にクレームが来ます。今日はこの2つの難所に絞って、コピペで動くコードと一緒に解説します。
この記事の要点
- ギャラリーで詰まるのは並べ方ではなく、遅延読み込みとライトボックスの2か所。
- 遅延読み込みは「
loading="lazy"で十分なケース」と「IntersectionObserverが要るケース」を先に切り分ける。ほとんどは前者で足りる。 - ファーストビューの1枚目は遅らせない。
width/heightかaspect-ratioを必ず入れてレイアウトのガタつき(CLS)を止める。 - ライトボックスは「拡大」より「閉じ方」と「キーボード送り(←→で前後の画像)」が本体。
inertで背後を固める。 - レスポンシブは
grid-template-columns: repeat(auto-fit, minmax(...))とsrcset/sizesの合わせ技で、メディアクエリをほぼ書かずに済む。
並べる前に、読み込み方を決める
ギャラリーを頼まれると、つい「何カラムにするか」「角丸は何px」から考えたくなります。でも、ユーザーの体感を決めるのはそこじゃない。最初の画面が出るまでの速さです。
だから僕は実装の前に、画像を3つの層に分けます。
| 層 | どの画像か | 読み込み方 |
|---|---|---|
| ファーストビュー | 開いてすぐ見える1〜2枚 | 遅らせない(eager + 1枚目は優先) |
| すぐ下のあたり | 少しスクロールで見える数枚 | ネイティブのloading="lazy" |
| ずっと下 | 何十枚もある一覧の後半 | loading="lazy"、必要ならIntersectionObserverで追加描画 |
この3層が頭に入っていれば、後の判断はほぼ機械的に決まります。逆にここを飛ばすと、「全部lazyにして1枚目が遅い」か「全部eagerでスマホが死ぬ」のどちらかに転びます。
loading="lazy" と IntersectionObserver、どっちを使う
ここがいちばん混乱するポイントなので、先に結論を置きます。
画像を「いつ読むか」だけが目的なら、loading="lazy"で終わりです。 imgに1単語足すだけで、ブラウザが画面に近づいたものから勝手に読んでくれる。自前のスクロール監視はいりません。対応状況はMDNのLazy loadingにまとまっていて、いまどきの主要ブラウザはこれで足ります。
<!-- これだけで遅延読み込みになる。スクロール監視のコードは不要 -->
<img src="/images/gallery/photo-960.webp" alt="制作実績のダッシュボード画面"
width="960" height="640" loading="lazy" decoding="async" />
ではいつIntersectionObserverを持ち出すか。「画像を読む」のではなく「DOMをまだ作っていない後半をスクロールに合わせて描き足す」ときです。たとえば画像が500枚あって、最初から全部のimgを置くとDOMが重い。そういう無限スクロール寄りのケースだけ、observerで「下端が見えたら次の20枚をappendする」を書きます。
判断を一覧にするとこうです。
imgは最初から全部置く(数十枚程度)→loading="lazy"だけでいい。- 画像が数百枚以上でDOMを分割描画したい → **
IntersectionObserver**で「もっと読む」を自動化。 - 背景画像(
background-image)を遅らせたい → ネイティブlazyが効かないので**IntersectionObserver**でクラス付与。
最後の点は地味に大事です。loading="lazy"はimgとiframeの属性なので、CSSの背景画像には効きません。背景で組んだヒーローやカードを遅らせたいなら、observerでビューに入ったらクラスを足す、という昔ながらの方法になります。
// 後半の追加描画 or 背景画像にだけ使う。普通の img には不要
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// 例: 見えたら次のページを読み込むフラグを立てる
entry.target.dispatchEvent(new CustomEvent("reveal"));
io.unobserve(entry.target); // 一度きりでいいので監視を外す
}
},
{ rootMargin: "200px 0px" } // 200px手前で先読みして「間に合わせる」
);
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
rootMarginに余白を入れて「画面に入る少し手前」で動かすのがコツです。ピッタリ0pxだと、ユーザーが見た瞬間に読み始めて、結局クルクルを見せることになります。
レイアウトは auto-fit と srcset で
並べ方は、正直あまり凝らないほうが事故りません。僕はまずCSS Gridのauto-fitに逃がします。これだけで、画面幅に応じてカラム数が勝手に増減します。メディアクエリをカラムごとに書く必要がありません。
.gallery-grid {
display: grid;
/* 最小220px、入るだけ並べる。スマホで1列、PCで自動的に複数列 */
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
カラム数(=表示幅)が決まったら、次はその幅に合った解像度の画像を出し分けます。ここでsrcsetとsizesの出番です。スマホには小さい画像、PCには大きい画像を、ブラウザが自動で選びます。考え方はMDNのレスポンシブ画像が一番分かりやすいです。
sizesは「この画像はだいたい画面のどれくらいの幅で表示されるか」をブラウザに教えるヒントです。3カラムなら1枚は約33vw、と書いておくと、ブラウザは33vwぶんの解像度の画像をsrcsetから選びます。ここを書かないと、ブラウザは「画面いっぱい(100vw)で出るのかも」と保守的に判断して、必要以上に大きい画像を引いてきます。
コピペで動くギャラリー(遅延読み込み+ライトボックス)
ここまでの判断を1つのReactコンポーネントにまとめます。Vite/Next.js/Astroのクライアントコンポーネントにそのまま貼れます。ポイントは3つだけ覚えてください。
- 1枚目だけ
eager+fetchPriority="high"、残りはloading="lazy"。 - **全
imgにwidth/heightとaspect-ratio**を入れてCLSを止める。 - **ライトボックスは「閉じ方」と「←→送り」と
inert**で背後を固める。
import { useEffect, useRef, useState } from "react";
import "./image-gallery.css";
export type GalleryImage = {
id: string;
src: string; // 既定(中サイズ)の画像
alt: string; // 画像の意味を書く。キーワード詰め込みは禁止
width: number;
height: number;
srcset?: string; // "small.webp 480w, mid.webp 960w, large.webp 1440w"
};
export function ImageGallery({ images }: { images: GalleryImage[] }) {
// 開いている画像の「位置」を持つ。null なら閉じている
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [broken, setBroken] = useState<Set<string>>(() => new Set());
const dialogRef = useRef<HTMLDivElement>(null);
const lastTrigger = useRef<HTMLButtonElement | null>(null);
const isOpen = openIndex !== null;
const current = isOpen ? images[openIndex] : null;
// ライトボックスのキーボード操作: Esc で閉じる、← → で前後へ
useEffect(() => {
if (!isOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpenIndex(null);
if (e.key === "ArrowRight") setOpenIndex((i) => (i! + 1) % images.length);
if (e.key === "ArrowLeft")
setOpenIndex((i) => (i! - 1 + images.length) % images.length);
}
window.addEventListener("keydown", onKey);
// 背後の本文をスクロールできないように固める
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// 開いた瞬間にダイアログへフォーカスを移す(スクリーンリーダー対応)
dialogRef.current?.focus();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// 閉じたら、開く前に押したサムネへフォーカスを戻す
lastTrigger.current?.focus();
};
}, [isOpen, images.length]);
if (images.length === 0) {
return <p className="gallery-empty">表示できる画像がまだありません。</p>;
}
return (
<section className="gallery" aria-label="画像ギャラリー">
{/* 背後の一覧は、開いている間 inert で操作不能にする */}
<div className="gallery-grid" {...(isOpen ? { inert: "" } : {})}>
{images.map((image, index) => {
if (broken.has(image.id)) {
return (
<span className="gallery-fallback" key={image.id}>
画像を読み込めませんでした
</span>
);
}
return (
<button
className="gallery-card"
key={image.id}
type="button"
onClick={(e) => {
lastTrigger.current = e.currentTarget; // 戻り先を覚えておく
setOpenIndex(index);
}}
>
<img
src={image.src}
srcSet={image.srcset}
sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
alt={image.alt}
width={image.width}
height={image.height}
// 1枚目だけ優先で読む。残りはネイティブの遅延読み込み
loading={index === 0 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
decoding="async"
style={{ aspectRatio: `${image.width} / ${image.height}` }}
onError={() => setBroken((s) => new Set(s).add(image.id))}
/>
</button>
);
})}
</div>
{current && (
<div
className="gallery-lightbox"
role="dialog"
aria-modal="true"
aria-label={current.alt}
tabIndex={-1}
ref={dialogRef}
onClick={() => setOpenIndex(null)} // 背景クリックで閉じる
>
<button
className="gallery-nav gallery-prev"
type="button"
aria-label="前の画像"
onClick={(e) => {
e.stopPropagation();
setOpenIndex((i) => (i! - 1 + images.length) % images.length);
}}
>
‹
</button>
<img
src={current.src}
srcSet={current.srcset}
sizes="100vw"
alt={current.alt}
width={current.width}
height={current.height}
onClick={(e) => e.stopPropagation()} // 画像クリックでは閉じない
/>
<button
className="gallery-nav gallery-next"
type="button"
aria-label="次の画像"
onClick={(e) => {
e.stopPropagation();
setOpenIndex((i) => (i! + 1) % images.length);
}}
>
›
</button>
<button
className="gallery-close"
type="button"
aria-label="閉じる"
onClick={() => setOpenIndex(null)}
>
×
</button>
</div>
)}
</section>
);
}
仕上げのCSSです。グリッドとライトボックスの最低限だけ。
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.gallery-card {
padding: 0;
border: 1px solid #d4d4d8;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
}
.gallery-card img {
display: block;
width: 100%;
height: auto; /* aspect-ratio に高さを任せる */
object-fit: cover;
background: #f4f4f5; /* 読み込み前の下地。チラつきを減らす */
}
.gallery-fallback {
display: grid;
place-items: center;
min-height: 180px;
background: #f4f4f5;
color: #71717a;
}
.gallery-lightbox {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: clamp(1rem, 5vw, 3rem);
background: rgb(0 0 0 / 0.88);
}
.gallery-lightbox img {
max-width: min(100%, 1100px);
max-height: 82vh;
width: auto;
height: auto;
object-fit: contain;
}
.gallery-nav,
.gallery-close {
position: absolute;
border: none;
border-radius: 999px;
background: rgb(255 255 255 / 0.9);
color: #18181b;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.gallery-nav { top: 50%; transform: translateY(-50%); padding: 0.5rem 0.8rem; }
.gallery-prev { left: clamp(0.5rem, 3vw, 2rem); }
.gallery-next { right: clamp(0.5rem, 3vw, 2rem); }
.gallery-close { top: 1rem; right: 1rem; padding: 0.4rem 0.7rem; }
/* 動きを減らす設定の人には、拡大時の派手なトランジションを出さない */
@media (prefers-reduced-motion: reduce) {
.gallery-card { transition: none; }
}
.gallery-empty { color: #71717a; }
inert属性については、効果と対応をMDNのinertで確認できます。これを背後のグリッドに付けると、ライトボックスを開いている間は後ろのサムネがTabでフォーカスされず、スクリーンリーダーからも消えます。手書きのフォーカストラップより、こちらのほうがはるかに事故りません。
データの渡し方とユースケース
UIとデータは分けます。CMSから取る場合でも、最後にこの形へ正規化しておくとテストが楽です。alt・width・heightは必須にして、欠けたら型で落とします。
import type { GalleryImage } from "./image-gallery";
export const galleryImages: GalleryImage[] = [
{
id: "dashboard",
src: "/images/gallery/dashboard-960.webp",
srcset:
"/images/gallery/dashboard-480.webp 480w, /images/gallery/dashboard-960.webp 960w, /images/gallery/dashboard-1440.webp 1440w",
alt: "リファクタ後の分析ダッシュボード画面",
width: 960,
height: 640,
},
{
id: "workshop",
src: "/images/gallery/workshop-960.webp",
alt: "研修で使ったレビュー観点のホワイトボード",
width: 960,
height: 720,
},
];
このギャラリーが効く場面は、だいたい次の4つです。
- 制作実績・ポートフォリオ。サムネから事例記事へ進めるよう、
altに「作品1」ではなく中身を書く。 - ECや有料コンテンツの詳細。サムネ・利用イメージ・比較・購入後画面を並べる。ただし1枚目以外を高解像度で先読みしない。離脱します。
- 研修・社内ナレッジ。手順スクショやBefore/Afterを束ねると再利用が効く。社外秘の写り込みチェックは忘れずに。
- 記事の図解ギャラリー。概念図と検証スクショを並べて理解を助ける。
僕がやらかした失敗3つ
正直に書きます。ここに挙げるのは全部、自分でやった事故です。
ひとつ目。ファーストビューの大きな1枚までloading="lazy"にした。 よかれと思って全部lazyにしたら、トップに置いたメイン画像の表示がワンテンポ遅れて、LCP(主要コンテンツが出るまでの時間)が悪化しました。見える1枚目は遅らせない。これだけで体感が変わります。lazy全般の詰まりどころは画像遅延読み込みの実装に整理したので、深掘りはそちらへ。
ふたつ目。width/heightを省いてレイアウトが跳ねた。 画像が1枚読み込まれるたびにカードの高さが変わって、ボタンを押そうとした瞬間に下にズレる。CLS(読み込み中のガタつき)です。width/heightかaspect-ratioを必ず入れて、読み込み前から場所を確保しておく。この記事のコードは両方入れています。
みっつ目。ライトボックスをマウス専用で作った。 Escで閉じない、開いている間に後ろがスクロールできる、閉じた後フォーカスが迷子になる。3点セットでクレームが来ました。いまは「Escで閉じる」「inertで背後を固める」「閉じたら呼び出し元へフォーカスを戻す」を最低ラインにしています。拡大表示は要するに特殊なモーダルなので、フォーカスの作法はモーダルダイアログの実装と同じです。厳密なフォーカストラップが要るならRadix UIやReact Ariaに寄せます。
公開前に手を動かして確かめること
ギャラリーは見た目で「できた」と錯覚しやすいので、必ず操作で確認します。僕のチェックはこの順です。
- DevToolsのNetworkで、初期表示時に読み込んだ画像の枚数を見る。下のほうの画像まで読んでいたらlazyが効いていない。
- スマホ幅(375px)で開いて、横スクロールが出ないか、1列で破綻しないか。
- ライトボックスをキーボードだけで操作する。
Enterで開く→←→で送り→Escで閉じる→フォーカスが元のサムネに戻るか。 - 壊れた画像URLを1つ混ぜて、フォールバックが出るか(落ちないか)。
- LighthouseでLCPとCLSを見る。CLSが跳ねたら
aspect-ratioの付け忘れを疑う。
軽いPlaywrightを1本置いておくと、リファクタのたびに安心できます。
import { expect, test } from "@playwright/test";
test("ライトボックスを開いてキーボードで閉じられる", async ({ page }) => {
await page.goto("/gallery");
await page.getByRole("region", { name: "画像ギャラリー" }).waitFor();
await page.locator(".gallery-card").first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
よくある質問
Q. loading="lazy"だけで十分? IntersectionObserverは要らない?
A. 普通のギャラリー(数十枚のimgを最初から置く)ならloading="lazy"だけで十分です。observerが要るのは、画像が数百枚以上でDOMを分割描画したいときや、CSSの背景画像を遅らせたいとき。順番として、まずネイティブを試して、足りなければobserverを足します。
Q. width/heightとaspect-ratio、どっちを書けばいい?
A. 両方入れておくのが安全です。width/height属性はブラウザに縦横比を伝えてCLSを防ぎ、CSSのaspect-ratioはレスポンシブで幅を100%に伸ばしたときも比率を保ちます。この記事のコードは両方入れています。
Q. ライトボックスは自作とライブラリ、どっちがいい?
A. 「開く・閉じる・前後送り・Escで閉じる・背後をinert」までなら自作で十分で、本記事のコードがそのまま使えます。厳密なフォーカストラップやアニメーションまで作り込むなら、Radix UIやReact Ariaに任せたほうが事故りません。判断軸はモーダルと同じです。
Q. srcsetの数字(480w / 960w)は何を表す?
A. 各画像ファイルの実ピクセル幅です。wはwidthの意味で、ブラウザはこの値とsizesで書いた表示幅、それと端末の解像度を見て、最適な1枚を自分で選びます。開発者が「この幅ならこれ」と分岐を書く必要はありません。
Q. Next.jsのnext/imageやAstroの<Image>を使うべき?
A. 自動でsrcsetや最適化をやってくれるので、フレームワークを使っているなら寄せる価値はあります。ただalt・サイズ・sizesをデータ側で揃える考え方は素のimgと同じです。まずこの記事の構造で責務を分けてから、画像コンポーネントだけ差し替えると安全です。
実際に試した結果
このギャラリーを自分のサイトに入れて、いちばん効いたのは「1枚目だけeager、残りはlazy」という地味な1行でした。全部lazyにしていた頃はトップ画像がワンテンポ遅れていたのが、消えました。aspect-ratioを全画像に入れてからは、画像を後から足してもカードがガタつかなくなり、CLSの数値が落ち着きました。
ライトボックスは、inertを背後に付けた瞬間に世界が変わりました。それまで手書きのフォーカストラップでバグを潰し続けていたのが、属性1つで「後ろは触れない・読まれない」が成立する。閉じたら呼び出し元のサムネにフォーカスを戻す、ここまで入れて、ようやくキーボードだけで一周できるギャラリーになりました。
並べ方に悩む時間を、遅延読み込みとライトボックスに回す。それだけで、公開後に出る問題はほとんど消えます。手を動かす順番をもう少し体系で詰めたい人は、研修・相談ものぞいてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。