【Next.js】スクロールギャラリーの作り方を解説|写真の固定とスマホ対応

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

Next.js で、スクロールに合わせて作品写真とキャプションが切り替わるギャラリーを作る手順です。position: sticky での写真の固定、画面に入ったかを調べる仕組みでの切替、Motion を使った横スクロール連動まで、コード付きで解説。写真が止まらない原因とスマホ対応もまとめます。

Next.js のスクロールギャラリーは写真の固定と切替で作れる

ポートフォリオや導入事例を Next.js で並べてみたら、3列のカードグリッドがフォルダの一覧のように見えた。

スクロールに合わせて、作品が1枚ずつ切り替わる見せ方にしたい。この記事は、そんな人に向けた実装ガイドです。

作り方の答えは単純です。写真の枠を position: sticky(スクロールしても止まって見える配置)で画面に残します。

あとは、スクロール位置に応じて「いま表示する作品の番号」を変えるだけです。

カルーセルのライブラリも GSAP も、最初は要りません。

この記事で分かることは次のとおりです。

  • 写真を画面に止めておく方法と、止まらないときの原因

  • 画面に入ったかを調べる仕組みで、作品を切り替えるスクロール連動ギャラリーのコード

  • Motion(旧 Framer Motion)で、縦スクロールを横の動きに変える方法

  • スマホ対応、画像の軽量化、動きを減らす設定への対応

グリッドよりスクロールギャラリーが向く3つの理由

カードを並べるグリッドは、たくさんの作品を一度に見せるには便利です。

ただ、作品の数が少なく、1つずつ読んでほしいときには向きません。理由は3つあります。

1つ目は、見る順番が生まれることです。グリッドでは全部が同じ大きさで並ぶため、どれが代表作か分かりません。

スクロールギャラリーは画面に1枚だけを大きく出すので、読者の目線が作品に集まります。

2つ目は、写真と説明がずれないことです。グリッドでは、キャプションがサムネイルの下に小さく付くだけです。

スクロールギャラリーなら、写真が切り替わるたびにタイトルや年度も入れ替わります。作品の話として読めます。

3つ目は、縦スクロールの流れを止めないことです。左右の矢印で操作するスライダーは、LP の途中で読者に操作を求めます。

スクロールの延長で切り替わる方が、ページの一部として自然に読まれます。

逆に、作品が20件を超える一覧や、タグで絞り込みたい一覧には向きません。そういう場合は、代表作4〜6件だけをスクロールギャラリーにし、残りはグリッドや別ページに回します。

スクロールギャラリーの完成イメージと3つの作り方

目指す動きは、次のようなものです。

  • セクションに入ると、写真が画面に貼り付く

  • スクロールを続けると、次の作品に切り替わる

  • タイトルと年度は、写真より少し遅れて入れ替わる

  • 右下の「2 / 5」のような表示で、今が何枚目かが分かる

作り方は手数の少ない順に3つあります。

  • Step1: CSS だけで写真を画面に止めておく(JavaScript なし)

  • Step2: 画面に入ったかを調べる仕組みで「今の作品」を切り替える(ブラウザで動かす設定が必要)

  • Step3: Motion を使い、スクロール量に合わせて横に流す(motion パッケージが必要)

迷ったら Step1 から始めてください。写真が画面に残るだけでも、グリッドとは見え方が大きく変わります。

写真そのものを切り替えたくなったら Step2 に、横に流れる演出が欲しくなったら Step3 に進みます。

Step1:CSS だけでスクロールギャラリーの写真を固定する

Step1 は、サーバーで組み立てる部品(サーバーコンポーネント)のまま作れます。左に止まって見える写真、右に作品ごとの説明を縦に並べます。

説明を読み進めても写真が画面に残るので、「いま見ている作品」がはっきりします。

components/StickyWorks.tsx
import Image from "next/image";

  const works = [
    { title: "House in Seto", year: "2024", src: "/works/1.jpg", text: "瀬戸の丘に建つ平屋の住宅。" },
    { title: "Atelier Light", year: "2025", src: "/works/2.jpg", text: "北向きの光を取り込むアトリエ。" },
    { title: "Small Library", year: "2025", src: "/works/3.jpg", text: "駅前の小さな図書室の改装。" },
  ];

  export function StickyWorks() {
    return (
      <section className="mx-auto grid max-w-6xl gap-10 px-6 py-24 lg:grid-cols-2">
        <div className="relative aspect-[4/5] lg:sticky lg:top-24 lg:self-start">
          <Image
            src={works[0].src}
            alt={works[0].title}
            fill
            sizes="(min-width: 1024px) 50vw, 100vw"
            className="object-cover"
          />
        </div>
        <div className="space-y-40 lg:py-24">
          {works.map((work) => (
            <article key={work.title}>
              <p className="text-sm text-zinc-500">{work.year}</p>
              <h3 className="mt-2 text-3xl font-semibold">{work.title}</h3>
              <p className="mt-4 text-zinc-600">{work.text}</p>
            </article>
          ))}
        </div>
      </section>
    );
  }

写真を固定する長さは説明側の高さで作る

止まって見える配置は、親要素の中でしか貼り付きません。親の高さが写真と同じなら、貼り付く距離はゼロです。

写真側を高くするのではなく、説明側を作品の数だけ積んで、親を縦に伸ばします。

space-y-40 の値を大きくするほど、1作品あたりのスクロール量が増えます。

sticky が効かないときに確認する4つの原因

「書いたのに貼り付かない」という相談で多いのは、次の4つです。

1つ目は、止める位置(top)を指定していないことです。Tailwind なら top-24 などを付けます。

2つ目は、祖先のどれかに、はみ出しを扱う overflow: hidden や overflow: auto が付いていることです。

3つ目は、グリッドや横並びの子で、高さが親いっぱいに伸びていることです。self-start を付けて、自分の高さに戻します。

4つ目は、親要素の高さが写真の枠と同じで、動く余地がないことです。

とくに2つ目は見つけにくい原因です。ページを包む要素や、html と body の両方に overflow-x-hidden を付けると、その下の止まって見える配置が全部効かなくなります。

横はみ出しを隠したいなら、overflow-x: clip を使えば、この配置を壊しません。

sticky ではなく GSAP でピン留めするなら、それ特有の問題は GSAPのpin-spacer解説 を見てください。

Step1 のスクロールギャラリーで足りないもの

Step1 の写真は最初の1枚のままです。説明は進んでも、写真は切り替わりません。

それでも、作品を読む流れは作れます。写真も切り替えたいなら、次の Step2 に進みます。

Step2:スクロールギャラリーの作品を切り替える

画面いっぱいの枠を、Step1 と同じ配置で画面に止めます。その下に、作品の数だけ「目印」の要素を並べます。

目印が画面の中央を通ったら、その番号を state(部品が覚えておく値)に入れて表示を切り替えます。

目印の検知には、画面に入ったかを調べる仕組みの Intersection Observer を使います。

Next.js では、このギャラリーのファイルだけに "use client"(この部品はブラウザで動かす、という印)を書きます。ページ全体をブラウザで動く部品にする必要はありません。

components/ScrollWorks.tsx
"use client";

  import Image from "next/image";
  import { useEffect, useRef, useState } from "react";

  type Work = { title: string; year: string; src: string };

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

    useEffect(() => {
      const io = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (!entry.isIntersecting) return;
            const i = Number(entry.target.getAttribute("data-i"));
            if (!Number.isNaN(i)) setIndex(i);
          });
        },
        // 画面の中央の線を通った目印だけを拾う
        { rootMargin: "-50% 0px -50% 0px" },
      );
      marks.current.forEach((el) => el && io.observe(el));
      return () => io.disconnect();
    }, []);

    return (
      <section className="relative">
        <div className="sticky top-0 h-[100svh] overflow-hidden bg-zinc-950" aria-hidden="true">
          {works.map((work, i) => (
            <Image
              key={work.src}
              src={work.src}
              alt=""
              fill
              sizes="100vw"
              priority={i === 0}
              className={
                "object-cover transition-opacity duration-700 " +
                (i === index ? "opacity-100" : "opacity-0")
              }
            />
          ))}
          {works.map((work, i) => (
            <div
              key={work.title}
              className={
                "absolute bottom-12 left-8 text-white transition duration-500 delay-150 " +
                (i === index ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0")
              }
            >
              <p className="text-sm text-white/70">{work.year}</p>
              <p className="mt-1 text-3xl font-semibold">{work.title}</p>
            </div>
          ))}
          <p className="absolute bottom-12 right-8 text-sm tabular-nums text-white/70">
            {index + 1} / {works.length}
          </p>
        </div>
        {works.map((work, i) => (
          <div
            key={work.title}
            data-i={i}
            ref={(el) => {
              marks.current[i] = el;
            }}
            className="h-[80svh]"
          >
            <h3 className="sr-only">{work.title}({work.year})</h3>
          </div>
        ))}
      </section>
    );
  }

写真は差し替えず、重ねて透明度を変える

src だけを書き換える方法もあります。ただ、切替のたびに画像の読み込みが走り、一瞬白くなります。

上のコードのように全作品の画像を重ねておき、opacity だけを変えるとクロスフェードになります。読み込みも最初に済むので、速くスクロールしても途切れません。

キャプションを写真より少し遅らせる

写真とタイトルが同じ瞬間に変わると、何が切り替わったのか目で追えません。

キャプションに delay-150 を付けて写真のあとから追いかけるようにすると、切替に順番が生まれます。0.1〜0.2 秒の差で十分です。

スクロールイベントで毎回位置を測らない

window の scroll イベントのたびに getBoundingClientRect で全作品の位置を測る書き方は、重くなりやすく番号もぶれます。

画面に入ったかを調べる仕組みなら、「目印が画面の中央を越えたか」だけをブラウザが知らせてくれます。表示の更新は切替の瞬間だけで済みます。

ギャラリー1作品あたりのスクロール量は枚数で決める

目印の高さが、1作品あたりのスクロール量になります。高さは svh(画面の高さの1%)で決めます。

4枚なら 80〜100svh、8枚なら 60svh 前後が扱いやすい目安です。

長くするほど落ち着いた印象になりますが、途中で離れる人も増えます。全体で画面5〜6枚分に収まるよう調整してください。

Step3:縦スクロールで横に流れるスクロールギャラリーを作る

作品を横一列に並べ、縦にスクロールすると横に流れていく見せ方もよく使われます。

この形は「今の番号」ではなく、「どこまでスクロールしたか」で動かします。進み具合は 0〜1 の数値です。

そのため、Motion(旧 Framer Motion)の useScroll(スクロール量を読み取る仕組み)と useTransform(値を変換する仕組み)が向いています。

パッケージは motion です。npm install motion で入れ、motion/react から読み込みます。

古いプロジェクトで framer-motion を使っている場合も、同じ名前の API があります。

components/HorizontalWorks.tsx
"use client";

  import { motion, useScroll, useTransform } from "motion/react";
  import { useRef } from "react";

  export function HorizontalWorks({ srcs }: { srcs: string[] }) {
    const ref = useRef<HTMLElement>(null);
    const { scrollYProgress } = useScroll({
      target: ref,
      offset: ["start start", "end end"],
    });
    // 最後の1枚が画面に収まる位置まで、横に動かす
    const end = "-" + ((srcs.length - 1) / srcs.length) * 100 + "%";
    const x = useTransform(scrollYProgress, [0, 1], ["0%", end]);

    return (
      <section ref={ref} className="relative" style={{ height: srcs.length * 100 + "svh" }}>
        <div className="sticky top-0 h-[100svh] overflow-hidden">
          <motion.div
            className="flex h-full"
            style={{ x, width: srcs.length * 100 + "%" }}
          >
            {srcs.map((src) => (
              <div key={src} className="h-full flex-1 p-8">
                <img src={src} alt="" className="h-full w-full rounded-2xl object-cover" />
              </div>
            ))}
          </motion.div>
        </div>
      </section>
    );
  }

セクションの高さが横に流れる距離になる

セクションの高さを「枚数 × 画面の高さ」にしているのは、縦にスクロールできる距離を作るためです。

その中で、写真の枠が画面に貼り付きます。進み具合(scrollYProgress)が 0 から 1 に進むあいだに、横の帯が左へ動きます。

offset の ["start start", "end end"] は、セクションの上端が画面の上端に来たときを 0 とする指定です。下端が画面の下端に来たときが 1 になります。

スクロール量の読み取りは再描画を起こさない

スクロール量を読み取る仕組みと値を変換する仕組みが返す値は、React の再レンダリングを起こさずに見た目を書き換えます。スクロールのたびに setState する書き方より軽いのはこのためです。

動きの基本や motion/react の読み込み方は、https://design-layer.com/ja/articles/nextjs-framer-motion-guide で詳しく説明しています。

スクロールギャラリーのスマホ対応とパフォーマンス

スクロール連動ギャラリーは、PC で作ってスマホで崩れることが多い部品です。公開前に次の点を確認してください。

  • 画面の高さは 100vh ではなく 100svh を使う。スマホのアドレスバーが出入りしても、高さががたつかない

  • スマホでは写真を止めない選択肢を持つ。Step1 のように大きい画面だけに指定すれば、小さい画面では普通の縦並びになる

  • 画像は sizes の指定を正しく書き、表示サイズの1.5倍程度に抑える。大きな画像を重ねると、切替が間に合わない

  • 固定ヘッダーがある場合は、写真を止める位置をヘッダーの高さぶん下げる。重なるとタイトルが隠れる

  • 見出しと次のセクションへの出口が見えるようにする。画面全体を長く占領すると、閉じ込められたように感じる

動きを減らす設定(prefers-reduced-motion)に対応する

OS で「視差効果を減らす」を選んでいる人には、切替やクロスフェードを止めて普通の縦リストで見せます。

Tailwind なら motion-reduce: の修飾子で transition を外せます。Motion では useReducedMotion フックで設定を読めます。

情報を動きだけに頼らないことが大切です。動きを止めても、同じ順番で作品名が読めるようにしておきます。

スクロールギャラリーをスクリーンリーダーとキーボードに対応させる

Step2 のコードでは、見た目の写真の枠に aria-hidden を付けています。各目印の中には sr-only の見出しを置いています。

高さだけの空の目印を並べると、読み上げでは作品名が飛んでしまいます。作品ページへのリンクを付けるなら、目印側に置くとキーボードでも順番にたどれます。

スクロールギャラリーでよくある失敗と直し方

  • 全作品を画面に固定(position: fixed)する。スクロールの距離が消え、ページの高さが足りなくなる。使うのは止まって見える配置と、目印用の高さ

  • 枚数を欲張る。15枚のピン留めはデモでは映えるが、LP では最後まで見られない。代表作だけにして、残りは「すべての作品」へのリンクにする

  • ヒーローの直後に置く。ヒーローの入場アニメーションの直後にピン留めが始まると、両方とも弱く見える。間に短い紹介文を1ブロック挟む

  • 祖先の overflow-x-hidden に気づかない。写真が止まらないときは、まず親をたどってはみ出しの指定を探す

完成したスクロールギャラリーをコピーして使う方法

DesignLayer の「コレクション・スクロールスライダー」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる
DesignLayer の「コレクション・スクロールスライダー」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる

次のような演出は、Step2 の番号管理だけでは作り込みに半日かかることがあります。

  • 写真が奥から手前に出てくる

  • 背景とコレクションの情報が同期する

  • サムネイルが下から積み上がる

その場合は、すでに組まれたギャラリーをブラウザで実際にスクロールして確かめる方法もあります。React と Tailwind のコードを、そのまま自分の Next.js に載せます。

DesignLayer のカタログでは、セクションをライブプレビューで動かしてから、Copy for Cursor でコードを AI エージェントに渡せます。

閲覧にアカウントやカードは不要で、無料のソースコピーは日本時間で1日3回です。カタログは https://design-layer.com/ja/components から開けます。

載せたあとの仕事は、写真・タイトル・枚数の差し替えです。作品数が少なく、見せ方を先に決めたいポートフォリオに向いています。

検索や絞り込みが要る一覧には向きません。そこは普通のグリッドで作ってください。

スクロールギャラリーについてよくある質問

  • 写真の枠を position: sticky で固定し、画面に入ったかを調べる仕組みで「今の作品の番号」を切り替えるのが基本です。画像は重ねておき、透明度を変えて切り替えるとちらつきません。

まとめ:Next.js でスクロールギャラリーを作る手順

  • Next.js のスクロールギャラリーは、写真を画面に止めておき、スクロール位置で表示する作品を切り替えれば作れる

  • 写真が止まらないときは、止める位置の指定・祖先のはみ出しの指定・親の高さを確認する

  • 写真は重ねて透明度で切り替え、キャプションは少し遅らせる

  • 横に流す演出は、Motion のスクロール量を読み取る仕組みで作る

  • スマホでは画面の高さの単位、画像サイズ、動きを減らす設定を必ず確認する

まずは Step1 のコードを自分の作品写真で動かしてみてください。ページ全体の構成から考えたい場合は https://design-layer.com/ja/articles/nextjs-portfolio-motion-guide 、スクロールで要素を表示する演出は https://design-layer.com/ja/articles/nextjs-scroll-reveal-guide が参考になります。

関連ガイド

【Next.js】スクロールギャラリーの作り方を解説|写真の固定とスマホ対応 | DesignLayer