【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 は、サーバーで組み立てる部品(サーバーコンポーネント)のまま作れます。左に止まって見える写真、右に作品ごとの説明を縦に並べます。
説明を読み進めても写真が画面に残るので、「いま見ている作品」がはっきりします。
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"(この部品はブラウザで動かす、という印)を書きます。ページ全体をブラウザで動く部品にする必要はありません。
"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 があります。
"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 に気づかない。写真が止まらないときは、まず親をたどってはみ出しの指定を探す
完成したスクロールギャラリーをコピーして使う方法

次のような演出は、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 が参考になります。


