【Next.js】スクロールすると作品が切り替わるギャラリーの作り方を解説

公開日: 2026年9月8日 · 読了目安 12 分

Next.js で、スクロールに合わせて写真とキャプションが切り替わる作品ギャラリーの作り方を解説します。CSS の sticky、Intersection Observer、完成セクションの流用の3通り。グリッドが地味に見える理由と、重い動きの避け方もまとめます。

先に結論:ギャラリーは「枚数」より「順番」でカッコよくなる

要約すると、作品一覧を今っぽく見せるには、写真を並べるより先に、スクロールしたときの順番を決める必要があります。画面が一度止まり、写真が差し替わり、タイトルとタグが少し遅れて追いつく。この追い方が見えると、同じ6枚でもポートフォリオに見えます。

作り方は3つあります。CSS の sticky で写真を固定する。Intersection Observer で「今どの作品か」を切り替える。すでに完成しているスクロールギャラリーを自分のページに載せる。カルーセルのライブラリから入る必要はありません。

この記事を読み終える頃には、スクロールすると作品が切り替わるギャラリーを1本出せるようになります。

普通のグリッドが地味に見える3つの理由

3列のカード、同じ高さのサムネイル、ホバーで少し拡大。悪い見せ方ではありません。ただ、スクロールしてもカードの関係が変わらないと、フォルダを開いているのと同じに見えます。

1つ目は、全部が同時に同じ大きさで並ぶことです。目線の順番がなく、どれが代表作かも分かりません。人が「今っぽい」と感じるのは、1枚が主で、次の1枚がそれに代わるときです。

2つ目は、キャプションが写真の下に固定されていることです。スクロールしても文章が写真を追わないと、作品の話ではなくサムネイルの話になります。

3つ目は、スライダーの矢印に任せることです。左右のボタンは便利ですが、LP の途中で操作を求めると、読む流れが切れます。縦スクロールの延長で切り替わる方が、ページの一部として残ります。

完成イメージ:この記事で作る動き

目指す動きは次のとおりです。

セクションに入ると、左(または全面)の写真が画面に貼り付く。スクロールを続けると、写真が次の作品に切り替わる。右のタイトル、年度、短い説明は、写真より0.1秒ほど遅れて変わる。進捗は点や番号で、今が何枚目か分かる。

枚数は4〜8枚。12枚を超えると、ピン留めの距離が長くなりすぎて、読者が逃げます。横スワイプのカルーセルや、masonry の静的グリッドは扱いません。まずは、縦スクロールで1枚ずつ代わる見せ方です。

作り方は3つある

同じ完成形でも、手数は分かれます。

方法1は CSS の sticky です。写真を画面に固定し、横(または下)に作品ごとの文章を長く置きます。依存は増えません。写真そのものを差し替える動きは、CSS だけでは書きにくいです。

方法2は Intersection Observer です。スクロール位置に応じて「今の index」を変え、1枚の画像と1つのキャプションを差し替えます。"use client" が必要です。ピンの長さやイージングは自分で決めます。

方法3は、ピン留めと切替まで組んであるギャラリーを自分のアプリに載せる方法です。タイミングを一から決めなくて済みます。写真とタイトルの差し替えが仕事になります。作品数が20を超える、検索やフィルタが要る、という一覧には向きません。

迷ったら、まず方法1で「写真が残る」体験だけ作り、物足りなければ方法2か3に進むのが早いです。

方法1:CSS の sticky で写真を画面に残す

サーバーコンポーネントのまま始められます。左に sticky の画像、右に作品ごとの文章を縦に並べます。スクロールしても写真が追いかけてくるので、グリッドより「見ている作品」がはっきりします。

ポイントはピンの高さを文章側で作ること

sticky は、親の高さが足りないとすぐ外れます。写真側を高くするのではなく、キャプション側を作品数だけ積みます。親に十分な min-height がある状態が、sticky の前提です。

components/StickyWorks.tsx
const works = [
  { title: "House in Seto", year: "2024", src: "/works/1.jpg" },
  { title: "Atelier Light", year: "2025", src: "/works/2.jpg" },
];

export function StickyWorks() {
  return (
    <section className="grid gap-10 px-6 py-24 lg:grid-cols-2">
      <div className="lg:sticky lg:top-24 lg:self-start">
        <img src={works[0].src} alt="" className="aspect-[4/5] w-full object-cover" />
      </div>
      <div className="space-y-32">
        {works.map((work) => (
          <article key={work.title}>
            <p className="text-sm text-zinc-500">{work.year}</p>
            <h2 className="mt-2 text-3xl font-semibold">{work.title}</h2>
          </article>
        ))}
      </div>
    </section>
  );
}

この段階で足らないもの

方法1の写真は、最初の1枚のままです。文章は進んでも、ビジュアルが代わらない。それでも「作品を読む」体験はできます。写真も代えたいなら、方法2です。

方法2:スクロール位置で「今の作品」を切り替える

写真を1枚の枠に固定し、各作品の文章ブロックが見えたら index を更新します。Next.js では、この部品だけ "use client" にします。ページ全体をクライアントコンポーネントにする必要はありません。

弱い指定と強い指定

弱い例は、スクロールイベントのたびに getBoundingClientRect を全部回すことです。重いし、ブレます。

強い例は、各作品の目印に Intersection Observer を付け、交差した番号だけ setState することです。画像の切替は opacity、キャプションは少し遅らせます。全部を同時に変えると、方法1と同じく「パチッ」と見えます。

components/ScrollWorks.tsx
"use client";

import { useEffect, useRef, useState } from "react";

export function ScrollWorks({
  works,
}: {
  works: { title: string; src: string }[];
}) {
  const [index, setIndex] = useState(0);
  const marks = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    const io = new IntersectionObserver(
      (entries) => {
        const visible = entries.find((e) => e.isIntersecting);
        const i = Number(visible?.target.getAttribute("data-i"));
        if (!Number.isNaN(i)) setIndex(i);
      },
      { rootMargin: "-40% 0px -40% 0px", threshold: 0 },
    );
    marks.current.forEach((el) => el && io.observe(el));
    return () => io.disconnect();
  }, []);

  return (
    <section className="relative">
      <div className="sticky top-0 flex h-[100svh] items-center justify-center">
        <img src={works[index].src} alt="" className="max-h-[70vh] object-cover" />
        <p className="absolute bottom-16 text-xl">{works[index].title}</p>
      </div>
      {works.map((work, i) => (
        <div
          key={work.title}
          data-i={i}
          ref={(el) => {
            marks.current[i] = el;
          }}
          className="h-[100svh]"
        />
      ))}
    </section>
  );
}

ピンの長さは枚数で決める

目印の高さを 100svh にすると、1作品あたり画面1枚分スクロールします。短いほどテンポが速い。長いほど「高級」に見えやすい一方、途中離脱も増えます。4枚なら 80〜100svh、8枚なら 60svh 前後が扱いやすいです。

prefers-reduced-motion のときは、切替をやめて普通の縦リストに戻します。情報は動きに依存させない、が原則です。

方法3:完成しているスクロールギャラリーを載せる

写真が奥から手前に出る、横のコレクション情報が背景と同期する、サムネイルが下から積み上がる。こうした塊は、方法2の index 管理だけでは半日かかることがあります。

その場合は、ブラウザで実際にスクロールしてから、React と Tailwind でできたギャラリーを自分の Next.js に載せる方法があります。DesignLayer のカタログは、その置き場です。閲覧にカードは不要です。https://design-layer.com/components

向き不向きを先に言います。作品数が少なく、世界観の演出を先に決めたいときに向きます。検索、タグ絞り込み、ページネーションが要る一覧、EC の商品グリッドには向きません。そこは方法1の文章リストか、普通のグリッドの方が速いです。

載せるときの仕事は3つです。ライブで最後までスクロールして「この速さでいいか」を決める。ソースを自分のリポジトリに置く。写真・タイトル・枚数だけ差し替える。ピンの実装を作り直す必要はありません。

無料のソースコピーは日本時間で1日1回です。今日ギャラリー1本、で足ります。ヒーロー側の入場は、https://design-layer.com/articles/nextjs-animated-hero-section-guide です。同じページに両方を強く置くと、テンポが喧嘩します。どちらか一方を主にしてください。

公開前チェックリスト

動きが付いたあとに見るのは、次の5点です。

1. スマホでピンが画面を占領しすぎないか。見出しと次のセクションへの出口が見えないと、閉じ込められたように感じます。

2. 画像の枚数とファイルサイズ。sticky 中に大きな JPEG を連続で替えると、切替が間に合いません。幅は表示サイズの1.5倍まで。

3. prefers-reduced-motion で、通常の縦リストに戻せるか。

4. キーボードとスクリーンリーダーで、作品名が順番に読めるか。空の 100svh だけを並べると、見出しが飛びます。

5. ヒーローの直後に置いていないか。入場の余韻が残るうちにピンが始まると、両方とも弱く見えます。間に短い説明を1ブロック挟むと安定します。

よくある失敗

失敗1:全作品を position: fixed にする。スクロール量が消えて、ページの高さが足りなくなります。使うのは sticky か、目印用の空の高さです。

失敗2:枚数を欲張る。15枚のピンはデモでは映えますが、LP では読まれません。代表作だけにして、残りは「すべての作品」へのリンクにします。

失敗3:画像と文字を同じフレームで切る。写真が変わった瞬間にタイトルも変わると、何が変わったのか分かりません。タイトルを1拍遅らせると、切替に順番が付きます。

よくある質問

実装で繰り返し出る質問です。

スクロールで画像が切り替わるギャラリーは、どう作ればよいですか?

写真を sticky で固定し、スクロール位置で index を更新するのが基本です。入場だけなら CSS、index 管理なら Intersection Observer、演出まで含めるなら完成セクションです。

Swiper やカルーセルの方が簡単ではありませんか?

左右操作が前提の UI なら、その方が早いです。LP の途中で「作品を読む」なら、縦スクロールの延長の方が流れが切れません。用途が違います。

GSAP の ScrollTrigger は必須ですか?

必須ではありません。ピンと index だけなら Observer で足ります。3D の飛び出しや、複数タイムラインの同期が必要になってから入れてください。

サーバーコンポーネントのまま作れますか?

方法1の sticky なら可能です。今の作品番号を state で持つなら、ギャラリー用のファイルだけ "use client" にします。

スマホでピン留めが重いときは?

同時に動かす画像を1枚にします。ぼかし背景のクロスフェード、巨大な固定動画は先に外します。枚数を4枚まで減らすだけでも、体感はかなり変わります。

作品数が20件ある場合は?

ピン留めは使わない方がよいです。代表の4〜6件だけスクロールギャラリーにして、残りはグリッドか別ページにします。全部をピンにすると、ページが長くなりすぎます。

アクセシビリティはどうしますか?

各作品に見出しを付け、空のスペーサーだけに情報を置かない。動きを切ったときも、同じ順番で読めるようにします。

既存の page.tsx のどこに置けばよいですか?

ヒーローと料金(または問い合わせ)の間です。ファーストビューの直後に置くと、入場とピンが連続します。短い紹介文のあとが扱いやすいです。

【Next.js】スクロールすると作品が切り替わるギャラリーの作り方を解説 | DesignLayer