スクロールで物語る(Scroll Storytelling)

このページでは、GSAPのScrollTriggerで実装するscrub・pin・横スクロール・パララックスなど、スクロールに連動した高度なアニメーションパターンをデモとコードで確認できます。

このページはGSAP(ScrollTrigger)専用です。要素を固定するだけなら CSS の position: sticky でもできますが、スクロール量に連動して進行度を制御すること(scrub によるタイムライン駆動・複数要素の速度差・横方向への変換)は、GSAPのScrollTriggerでなければ実装が難しい部分です。
6つのデモを1画面に並べるため、各デモを高さ 400px の独立スクロールコンテナに分けています。 ページ全体をスクロールする構成なら pin: true でセクションを固定するのが定石ですが、 ここでは ScrollTrigger の scroller オプションでそのコンテナを指定し、固定は CSS の position: sticky で行っています。 下のコードはこの構成のまま掲載しています。ページ全体をスクロールする構成で使う場合は、scroller の指定と sticky の枠組みを外し、pin: true と end: '+=…' による指定に置き換える必要があります。

↓ 下にスクロールして6つのデモを体験してください。各セクションのコードは下部で確認できます。

プレビュー

読み込み中...

各デモのコード解説

各デモをクリックして実装コードを確認してください。すべてのデモは useGSAP({ scope: containerRef }) でスコープを設定し、アンマウント時に自動クリーンアップされます。

01 進捗バー(scrub)

スクロール量に連動して左から右へ伸びるプログレスバー。scrub: true でスクロール位置に即時追従します。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function ProgressBarDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    gsap.to('.st-progress-bar', {
      scaleX: 1,
      ease: 'none',
      scrollTrigger: {
        trigger: '.st-progress-inner',  // 高さを稼いだコンテンツ本体
        scroller: container.current,    // ページ全体ではなく、この枠内のスクロールに連動させる
        start: 'top top',
        end: 'bottom bottom',
        scrub: true,                    // スクロール位置に即時追従
      },
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* バーは sticky で枠の上端に貼り付ける */}
      <div style={{ position: 'sticky', top: 0, height: '6px', zIndex: 10 }}>
        {/* 色や角丸などの見た目は省略 */}
        <div className="st-progress-bar h-full origin-left" style={{ transform: 'scaleX(0)' }} />
      </div>
      <div className="st-progress-inner" style={{ height: '1200px' }}>
        スクロールするコンテンツ
      </div>
    </div>
  );
}

02 テキスト演出(scrub)

複数行のテキストが1行ずつスクロールに連動してフェードイン。gsap.utils.toArray で一括設定できます。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function TextRevealDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    // 初期値は gsap.set() で先に確定させる(HTML 側に style={{ opacity: 0 }} を書かずに済む)
    gsap.set('.st-text-line', { opacity: 0, y: 60 });

    // gsap.utils.toArray で複数要素を取得してまとめて設定
    gsap.utils.toArray<HTMLElement>('.st-text-line').forEach((line) => {
      gsap.to(line, {
        opacity: 1,
        y: 0,
        scrollTrigger: {
          trigger: line,
          scroller: container.current,  // ページ全体ではなく、この枠内のスクロールに連動させる
          start: 'top 85%',
          end: 'top 40%',
          scrub: 1,                     // 数値指定で1秒の遅延をつけてなめらかに追従
        },
      });
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* 高さ 1600px の余白がスクロール距離になる */}
      <div style={{ height: '1600px' }}>
        {/* sticky で枠の中央に貼り付ける */}
        <div style={{ position: 'sticky', top: '50%', transform: 'translateY(-50%)' }}>
          {['スクロールで', '物語を', '紡ぐ。'].map((text) => (
            <p key={text} className="st-text-line">{text}</p>
          ))}
        </div>
      </div>
    </div>
  );
}

03 画像ズーム(scrub)

スクロールに連動して画像が scale: 1 → 1.4 にゆっくりズーム。overflow: hidden で拡大をクリップします。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function ImageZoomDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    gsap.to('.st-zoom-inner', {
      scale: 1.4,
      ease: 'none',
      scrollTrigger: {
        trigger: '.st-zoom-trigger',  // 高さを稼いだ余白(=スクロール距離)
        scroller: container.current,  // ページ全体ではなく、この枠内のスクロールに連動させる
        start: 'top top',
        end: 'bottom bottom',
        scrub: true,
      },
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* 高さ 1200px の余白がスクロール距離になる */}
      <div className="st-zoom-trigger" style={{ height: '1200px' }}>
        {/* sticky で 400px の表示窓を固定し、overflow: hidden で拡大分をクリップする */}
        <div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
          {/* 背景画像などの見た目は省略 */}
          <div className="st-zoom-inner" style={{ width: '100%', height: '100%' }} />
        </div>
      </div>
    </div>
  );
}

04 パララックス

背景が前景より遅い速度で動くパララックス効果。yPercent で要素を縦方向に移動させます。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function ParallaxDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    // 背景と前景で速度差をつけるため、ScrollTrigger を2本立てる
    gsap.to('.st-parallax-bg', {
      yPercent: -40,  // 背景は大きく動かす(=遅く見える)
      ease: 'none',
      scrollTrigger: {
        trigger: '.st-parallax-trigger',
        scroller: container.current,  // ページ全体ではなく、この枠内のスクロールに連動させる
        start: 'top top',
        end: 'bottom bottom',
        scrub: true,
      },
    });

    gsap.to('.st-parallax-fg', {
      yPercent: -15,  // 前景は控えめに動かす
      ease: 'none',
      scrollTrigger: {
        trigger: '.st-parallax-trigger',
        scroller: container.current,
        start: 'top top',
        end: 'bottom bottom',
        scrub: true,
      },
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* 高さ 1200px の余白がスクロール距離になる */}
      <div className="st-parallax-trigger" style={{ height: '1200px' }}>
        {/* sticky で 400px の表示窓を固定する */}
        <div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
          {/* 背景は 150% の高さを持たせ、上下に動かす余白を作る。見た目は省略 */}
          <div
            className="st-parallax-bg"
            style={{ position: 'absolute', top: '-25%', left: 0, right: 0, height: '150%' }}
          />
          <div className="st-parallax-fg" style={{ position: 'absolute', inset: 0 }}>
            前景コンテンツ
          </div>
        </div>
      </div>
    </div>
  );
}

05 ピン留めコンテンツ切り替え

セクションをピン留めした状態で、スクロールに連動して中のスライドが順番に切り替わります。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

const SLIDES = ['Step 1:始まり', 'Step 2:展開', 'Step 3:結末'];

export default function PinSwitchDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    const slides = gsap.utils.toArray<HTMLElement>('.st-pin-slide');

    // 2枚目以降を先に隠しておく(timeline の外で初期値を確定させる)
    gsap.set(slides.slice(1), { autoAlpha: 0, y: 40 });

    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: '.st-pin-trigger',
        scroller: container.current,  // ページ全体ではなく、この枠内のスクロールに連動させる
        start: 'top top',
        end: 'bottom bottom',         // 余白の分だけスクロールしたら終了
        scrub: 1,
      },
    });

    // 前のスライドを送り出し、同じタイミング('<')で次を迎え入れる
    slides.forEach((slide, i) => {
      if (i > 0) {
        tl.to(slides[i - 1], { autoAlpha: 0, y: -40, duration: 1 });
        tl.to(slide, { autoAlpha: 1, y: 0, duration: 1 }, '<');
      }
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* 高さ 1600px の余白がスクロール距離になる */}
      <div className="st-pin-trigger" style={{ height: '1600px' }}>
        {/* 固定は ScrollTrigger の pin ではなく CSS の position: sticky で行う */}
        <div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
          {SLIDES.map((label) => (
            <div key={label} className="st-pin-slide" style={{ position: 'absolute', inset: 0 }}>
              {label}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// autoAlpha は opacity と visibility をまとめて制御するGSAPの特殊プロパティ

06 横スクロール演出

縦スクロールを横移動に変換。セクションをピン留めしてカードを横に流します。

tsx
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger, useGSAP);

const CARDS = ['Card A', 'Card B', 'Card C', 'Card D'];

export default function HorizontalScrollDemo() {
  const container = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
    const scroller = container.current;
    const track = scroller?.querySelector<HTMLElement>('.st-h-track');
    if (!scroller || !track) return;

    // 横にはみ出している量=トラックの実幅 − 表示幅。カード幅を決め打ちしないので
    // カード枚数や幅が変わっても計算し直す必要がない
    const totalShift = track.scrollWidth - scroller.offsetWidth;

    gsap.to(track, {
      x: -totalShift,
      ease: 'none',
      scrollTrigger: {
        trigger: '.st-h-trigger',
        scroller,           // ページ全体ではなく、この枠内のスクロールに連動させる
        start: 'top top',
        end: 'bottom bottom',
        scrub: 1,
      },
    });
  }, { scope: container });

  return (
    // 枠の中だけがスクロールする独立コンテナ。overflowX: hidden で横のはみ出しを隠す
    <div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
      {/* カード枚数 × 400px の余白がスクロール距離になる */}
      <div className="st-h-trigger" style={{ height: `${400 * CARDS.length}px` }}>
        {/* 固定は ScrollTrigger の pin ではなく CSS の position: sticky で行う */}
        <div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
          <div
            className="st-h-track"
            style={{ display: 'flex', height: '100%', width: `${CARDS.length * 100}%` }}
          >
            {CARDS.map((label) => (
              <div
                key={label}
                className="st-h-card"
                style={{ width: `${100 / CARDS.length}%`, height: '100%', flexShrink: 0 }}
              >
                {label}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

GSAP ScrollTrigger のポイント

  • • gsap.registerPlugin(ScrollTrigger, useGSAP) はコンポーネント外(モジュールレベル)で一度だけ呼ぶ
  • • scrub: true はスクロールに即時追従。scrub: 1 は1秒の遅延でなめらかに追従
  • • scroller でスクロール対象のコンテナを指定。省略するとページ全体のスクロールに連動する。ページ全体構成なら pin: true でセクションを固定するのが定石だが、枠内スクロール構成では CSS の position: sticky で代替する
  • • ScrollTrigger.config({ ignoreMobileResize: true }) でモバイルのアドレスバー伸縮による再計算を抑える
  • • start / end は "top top" のように「要素の位置 ビューポートの位置」で指定。"+= 400" はピクセル数での相対指定
  • • useGSAP({ scope: containerRef } ) でクリーンアップを自動化。アンマウント時にScrollTriggerも破棄される
  • • autoAlpha は GSAP 独自のプロパティで opacity と visibility を連動させる
💡 上記のデモは、React / Next.jsです。Next.jsの基本セットアップは 公式ドキュメントを参照してください。