【Next.js】GSAP ScrollTrigger の使い方を useGSAP の後片付けまで解説

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

Next.js の App Router で GSAP の ScrollTrigger を使う手順です。useGSAP と scope による自動の後片付け、scrub と pin の書き方、window is not defined や開発中の二重実行の直し方、スマホと動きを減らす設定への対応までコード付きで解説します。

Next.js で GSAP ScrollTrigger が思いどおりに動かないとき

Next.js の LP に、スクロールに合わせて動く演出を入れたい。GSAP の ScrollTrigger を入れてみたら、エラーが出たり動きが二重になったりした。

ScrollTrigger は、スクロール位置に合わせてアニメーションを動かす GSAP の機能です。App Router でも、書く場所と後片付けを押さえれば素直に動きます。

答えは次のとおりです。ブラウザで動かす印("use client")を付けたファイルで、公式の useGSAP フックの中に書きます。

useGSAP に scope(要素を探す範囲)を渡すと、部品が消えるときに作った動きがまとめて片付きます。開発中の二重実行や、ページ移動後の位置ずれもこれで防げます。

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

  • gsap と @gsap/react のインストールと、プラグインの登録

  • useGSAP と scope を使った、後片付けを書かなくてよい書き方

  • スクロール量に動きを合わせる scrub と、画面に固定する pin

  • window is not defined などのよくあるエラーの直し方

  • スマホと「動きを減らす」設定への合わせ方

GSAP ScrollTrigger を useGSAP で使う理由

React の useEffect に GSAP をそのまま書いても、最初は動きます。ただし後片付けを書き忘れやすく、次の問題が起きます。

  • 開発中は同じ動きが2回作られ、マーカーやアニメーションが二重になる

  • 別のページへ移動しても古いスクロール設定が残り、開始位置がずれる

  • クラス名で要素を探すと、同じ部品を2つ置いたときに両方へ効いてしまう

useGSAP は、GSAP が用意している React 用のフックです。中で作ったアニメーションとスクロール設定を記録し、部品が消えるときに元へ戻します。

scope に部品の外枠を渡すと、".card" のようなクラス名での指定がその枠の中だけに絞られます。

また useGSAP の中身は、サーバーでは実行されません。window や document を触る処理も安心して書けます。

Step1:GSAP と ScrollTrigger をインストールして登録する

使うパッケージは2つです。gsap が本体で、@gsap/react に useGSAP が入っています。

ScrollTrigger は gsap に同梱されています。別のパッケージを入れる必要はありません。

bash
npm install gsap @gsap/react

プラグインの登録はブラウザで動くファイルに書く

プラグインは、使う前に gsap.registerPlugin で登録します。"use client" を付けたファイルの、import のすぐ下に書くのが簡単です。

登録は何度呼んでも問題ありません。部品ごとに書いても、共通のファイルにまとめても動きます。

useGSAP も一緒に登録しておくと、React の版の違いによる不具合を避けられます。GSAP の公式ドキュメントでも勧められている書き方です。

components/FeatureCards.tsx(先頭)
"use client";

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

Step2:useGSAP と scope で ScrollTrigger を後片付けする

まずは、カードが画面に入ったときに1回だけ下から現れる動きです。スクロール量には合わせず、決まった時間で再生します。

components/FeatureCards.tsx
"use client";

import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const features = ["導入は1日で完了", "毎週アップデート", "日本語でサポート"];

export function FeatureCards() {
  const container = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      gsap.from(".feature-card", {
        y: 40,
        autoAlpha: 0,
        duration: 0.8,
        ease: "power2.out",
        stagger: 0.15,
        scrollTrigger: {
          trigger: container.current,
          start: "top 75%",
          once: true,
        },
      });
    },
    { scope: container }
  );

  return (
    <section ref={container} className="mx-auto grid max-w-5xl gap-6 px-6 py-24 md:grid-cols-3">
      {features.map((label) => (
        <div key={label} className="feature-card rounded-2xl border border-zinc-200 p-8 text-lg font-medium">
          {label}
        </div>
      ))}
    </section>
  );
}

コードのポイント

  • useGSAP の2つ目の引数に { scope: container } を渡す。".feature-card" はこの section の中だけで探される

  • start: "top 75%" は「section の上端が、画面の上から75%の位置に来たら」という意味

  • once: true で、1回通り過ぎたらスクロールの監視をやめる。上に戻っても再生し直さない

  • autoAlpha は、透明度と visibility をまとめて変える指定。見えない間はクリックも受けない

page.tsx はサーバーのまま部品を読み込む

page.tsx には "use client" を書かず、この部品を import して置くだけにします。ブラウザで動くのはこのファイルだけで、ページの他の部分はサーバーで組み立てられます。

JavaScript が動く前は、カードは普通に表示されています。最初の画面より下に置けば、読み込み直後のちらつきも目に入りません。

Step3:scrub で ScrollTrigger をスクロール量に連動させる

scrub を付けると、アニメーションの進み具合がスクロール量に結びつきます。下へ動かせば進み、上へ戻せば巻き戻ります。

scrub: true はスクロールにぴったり付いてきます。scrub: 1 のように数字を渡すと約1秒遅れて追いつくので、動きが柔らかくなります。

次の例では、背景がゆっくり縮みながら見出しが横へ流れます。2つの動きを timeline(動きを順番に並べる入れ物)にまとめ、1つのスクロール設定で動かします。

components/ScrubBanner.tsx
"use client";

import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

export function ScrubBanner() {
  const container = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: container.current,
          start: "top bottom",
          end: "bottom top",
          scrub: 1,
        },
      });

      tl.fromTo(".scrub-bg", { scale: 1.25 }, { scale: 1, ease: "none" })
        .fromTo(".scrub-title", { xPercent: -15 }, { xPercent: 15, ease: "none" }, 0);
    },
    { scope: container }
  );

  return (
    <section ref={container} className="relative flex h-[80vh] items-center overflow-hidden">
      <div className="scrub-bg absolute inset-0 bg-gradient-to-br from-sky-500 to-indigo-800" />
      <h2 className="scrub-title relative w-full text-center text-5xl font-semibold text-white md:text-7xl">
        スクロールで動く見出し
      </h2>
    </section>
  );
}

start と end の読み方

start: "top bottom" は、要素の上端と画面の下端が重なったときに始まるという意味です。前の語が要素、後ろの語が画面の位置です。

end: "bottom top" なら、要素の下端が画面の上端を過ぎたときに終わります。画面に入ってから出るまでで、ちょうど1回分進みます。

scrub のときは ease: "none" にします。スクロール量と進み方が1対1になり、指の動きと絵がずれません。

Step4:pin で ScrollTrigger のセクションを画面に固定する

pin を使うと、スクロールしている間だけ要素を画面に固定できます。固定している間に、横へ流れるパネルや写真の切り替えを見せる演出です。

次の例は、縦のスクロールで4枚のパネルを横へ送ります。

components/PinnedSteps.tsx
"use client";

import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const panels = ["企画", "デザイン", "実装", "公開"];

export function PinnedSteps() {
  const wrapper = useRef<HTMLDivElement>(null);
  const section = useRef<HTMLElement>(null);
  const track = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const el = track.current;
      if (!el) return;
      // 横に送る距離 = 並んだパネルの幅 - 画面の幅
      const distance = () => el.scrollWidth - window.innerWidth;

      gsap.to(el, {
        x: () => -distance(),
        ease: "none",
        scrollTrigger: {
          trigger: section.current,
          start: "top top",
          end: () => "+=" + distance(),
          pin: section.current,
          scrub: 1,
          invalidateOnRefresh: true,
        },
      });
    },
    { scope: wrapper }
  );

  return (
    <div ref={wrapper}>
      <section ref={section} className="h-svh overflow-hidden bg-zinc-950 text-white">
        <div ref={track} className="flex h-full w-max">
          {panels.map((label, i) => (
            <div key={label} className="flex w-screen shrink-0 items-center justify-center">
              <p className="text-4xl font-semibold md:text-6xl">
                {String(i + 1).padStart(2, "0")} {label}
              </p>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

pin を使うときの注意

  • 部品の一番外側の要素は固定しない。外側に div を1つ置き、その中の section を固定する(GSAP が固定用の枠で包むため)

  • end を関数にし、invalidateOnRefresh: true を付ける。画面幅が変わったときに距離を測り直す

  • 固定する要素の親に transform を付けない。固定の位置がずれる原因になる

スマホでは、横に送る距離が長く感じられがちです。後の節で、画面幅ごとに動きを分ける方法を紹介します。

ScrollTrigger でよくあるエラーと直し方

window is not defined と出る

Next.js は、ブラウザで動く部品も一度サーバーで HTML にします。サーバーには window がないので、部品の外側や描画の途中で触るとこのエラーになります。

window.innerWidth などを読む処理は、useGSAP の中か、Step4 のような値を返す関数の中に移します。

"use client" を付け忘れたファイルで useGSAP を呼んだ場合も、フックが使えないというエラーになります。

開発中だけ動きが二重になる

開発モードの React は、確認のために部品を一度作って消し、もう一度作ります(Strict Mode と呼ばれる仕組み)。後片付けのない useEffect で書くと、スクロール設定が2つ残ります。

useGSAP に書き直せば、1回目の分が自動で戻されます。本番では二重に実行されませんが、ページ移動のあとに古い設定が残る原因と同じなので直しておきます。

マーカーを消し忘れて公開してしまう

markers: true は、開始と終了の位置を線で表示する確認用の設定です。調整中は便利ですが、消し忘れるとそのまま公開されます。

markers: process.env.NODE_ENV === "development" と書いておけば、本番のビルドでは表示されません。

画像が読み込まれると開始位置がずれる

スクロールの設定は、最初に要素の位置を測って開始と終了を決めます。そのあとで画像が読み込まれて高さが変わると、測った位置が古くなります。

まずは next/image に width と height を渡し、読み込み前から場所を確保します。高さが後から変わる場合は、読み込み完了時に ScrollTrigger.refresh() で測り直します。

components/HeroImage.tsx
"use client";

import Image from "next/image";
import { ScrollTrigger } from "gsap/ScrollTrigger";

export function HeroImage() {
  return (
    <Image
      src="/hero.jpg"
      alt="新しいオフィスの外観"
      width={1600}
      height={900}
      className="h-auto w-full"
      // 読み込みが終わったら、開始と終了の位置を測り直す
      onLoad={() => ScrollTrigger.refresh()}
    />
  );
}

スマホと動きを減らす設定に ScrollTrigger を合わせる

pin や大きな移動は、スマホの小さな画面では読みにくくなることがあります。また OS で「視差効果を減らす」を選んでいる人には、大きな動きを見せない配慮が要ります。

gsap.matchMedia を使うと、画面幅や動きを減らす設定ごとに別の動きを登録できます。条件が変わると、前の動きは自動で元に戻ります。

動きを減らす設定のときは何も登録せず、カードは最初から表示したままにします。透明から現す動きを作らないので、中身が見えなくなる心配もありません。

components/ResponsiveFeatureCards.tsx
"use client";

import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const features = ["導入は1日で完了", "毎週アップデート", "日本語でサポート"];

export function ResponsiveFeatureCards() {
  const container = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const mm = gsap.matchMedia(container);

      mm.add(
        {
          isDesktop: "(min-width: 768px)",
          isMobile: "(max-width: 767px)",
          reduceMotion: "(prefers-reduced-motion: reduce)",
        },
        (context) => {
          const { isDesktop, reduceMotion } = context.conditions as {
            isDesktop: boolean;
            reduceMotion: boolean;
          };
          // 動きを減らす設定なら何もしない(最初から表示されたまま)
          if (reduceMotion) return;

          gsap.from(".feature-card", {
            y: isDesktop ? 60 : 20,
            autoAlpha: 0,
            duration: isDesktop ? 0.8 : 0.5,
            stagger: isDesktop ? 0.15 : 0.08,
            scrollTrigger: { trigger: container.current, start: "top 80%", once: true },
          });
        }
      );

      return () => mm.revert();
    },
    { scope: container }
  );

  return (
    <section ref={container} className="mx-auto grid max-w-5xl gap-6 px-6 py-24 md:grid-cols-3">
      {features.map((label) => (
        <div key={label} className="feature-card rounded-2xl border border-zinc-200 p-8 text-lg font-medium">
          {label}
        </div>
      ))}
    </section>
  );
}

完成した GSAP ScrollTrigger のセクションをコピーして使う方法

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

固定とスクロール連動を組み合わせた演出は、写真の配置や固定の長さの調整に時間がかかります。手早く形にしたいときは、完成したセクションを動かして確かめる方法もあります。

DesignLayer の「ビフォーアフター・スクロール」は、GSAP の ScrollTrigger で作ったセクションです。画面に留まったままスクロールすると、Before から After へ写真が切り替わり、続けて次の写真が広がります。

動きを減らす設定にしている人向けの表示も入っています。

カタログのライブプレビューで動きを見てから、詳細ページの「Cursor 用にコピー」を押します。統合の指示・ライブプレビューの URL・本体の TSX・npm 依存がまとめてコピーされるので、Cursor や Claude に貼って使います。

閲覧とライブプレビューは無料で、アカウントもカードも要りません。無料のソースコピーは日本時間で1日3回までです。

Plus(月 990 円)と Pro(月 2,980 円)では、コンポーネントのコピーが無制限になります。カタログは https://design-layer.com/ja/components から開けます。

GSAP ScrollTrigger についてよくある質問

  • 使えます。ScrollTrigger は以前から無料です。

    2025年の GSAP 3.13 からは、SplitText などの有料だったプラグインも無料になりました。商用サイトでも使えます。

    細かな利用条件は、GSAP の公式サイトで確認してください。

まとめ:Next.js で GSAP ScrollTrigger を使う手順

  • gsap と @gsap/react を入れ、"use client" のファイルでプラグインと useGSAP を登録する

  • 動きは useGSAP の中に書き、scope に部品の外枠を渡す。後片付けは自動になる

  • スクロール量に合わせるなら scrub、画面に固定するなら pin を使う

  • markers は開発中だけ表示し、高さが後から変わるときは refresh で測り直す

  • gsap.matchMedia で、スマホと動きを減らす設定には軽い動きを用意する

まずは Step2 のカードの登場から試してください。GSAP を使わずに画面に入ったときのフェードを作る方法は、https://design-layer.com/ja/articles/nextjs-scroll-reveal-guide で解説しています。

写真を左右で見比べるスライダーの作り方は、https://design-layer.com/ja/articles/nextjs-before-after-slider-guide にまとめています。

【Next.js】GSAP ScrollTrigger の使い方を useGSAP の後片付けまで解説 | DesignLayer