React + GSAP + ScrollTrigger

画面をスクロールする際、複数の画像要素に対してレスポンシブな(画面幅に応じた)clip-path アニメーションとパララックスを同時に適用することです。

環境

vite@7.1.11
react@19.2.0
gsap@3.13.0
gsap/react@2.1.2

リポジトリはこちら

コードの主要な機能と説明

import { useRef } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import './App.scss';

gsap.registerPlugin(ScrollTrigger);// ScrollTriggerの登録

function ScrollComponent() {

  const container = useRef(null);

  useGSAP(() => {

    let mm = gsap.matchMedia();
    const targets = gsap.utils.toArray('.image--01', container.current);

    targets.forEach(target => {

      const children = target.children[0];

      if (!children) return;// 子要素がなければスキップ

      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: target,
          start: 'top bottom',
          end: 'bottom top',
          scrub: 1,
          markers: false
        }
      });

      mm.add('(min-width: 769px)', () => {// ウィンドウが769px以上の場合

        tl.set(children, {// clip-path初期設定
          '--p1-x': '25%',
          '--p1-y': '0%',
          '--p2-x': '75%',
          '--p2-y': '25%',
          '--p3-x': '100%',
          '--p3-y': '100%',
          '--p4-x': '0%',
          '--p4-y': '100%',
        });

        tl.to(children, {
          '--p1-x': '0%',
          '--p1-y': '0%',
          '--p2-x': '100%',
          '--p2-y': '0%',
          '--p3-x': '100%',
          '--p3-y': '100%',
          '--p4-x': '0%',
          '--p4-y': '100%',
        })
        .to(children, {
          '--p1-x': '0%',
          '--p1-y': '0%',
          '--p2-x': '100%',
          '--p2-y': '0%',
          '--p3-x': '75%',
          '--p3-y': '100%',
          '--p4-x': '25%',
          '--p4-y': '75%',
        });
      });

      mm.add('(max-width: 768px)', () => {// ウィンドウが768px以下の場合

        tl.set(children, {// clip-path初期設定
          '--p1-x': '15%',
          '--p1-y': '0%',
          '--p2-x': '85%',
          '--p2-y': '15%',
          '--p3-x': '100%',
          '--p3-y': '100%',
          '--p4-x': '0%',
          '--p4-y': '100%',
        });

        tl.to(children, {
          '--p1-x': '0%',
          '--p1-y': '0%',
          '--p2-x': '100%',
          '--p2-y': '0%',
          '--p3-x': '100%',
          '--p3-y': '100%',
          '--p4-x': '0%',
          '--p4-y': '100%',
          duration: 2
        })
        .to(children, {
          '--p1-x': '0%',
          '--p1-y': '0%',
          '--p2-x': '100%',
          '--p2-y': '0%',
          '--p3-x': '100%',
          '--p3-y': '100%',
          '--p4-x': '0%',
          '--p4-y': '100%',
          duration: 1
        })
        .to(children, {
          '--p1-x': '0%',
          '--p1-y': '0%',
          '--p2-x': '100%',
          '--p2-y': '0%',
          '--p3-x': '85%',
          '--p3-y': '100%',
          '--p4-x': '15%',
          '--p4-y': '85%',
          duration: 2
        });
      });

      const img = children.children[0];

      gsap.to(img, {// 画像のパララックス
        y: '-30%',
        ease: 'none',
        scrollTrigger: {
          trigger: target,
          start: 'top bottom',
          end: 'bottom top',
          scrub: true,
          markers: false
        }
      });
    });

    return () => {

      targets.forEach(target => {

        if (target.cleanup) target.cleanup();
      })
    }
  }, {scope: container, dependencies: []});

  return (
    <>
      <br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
        <div ref={container} className="animation-container">
          <div className="image--01">
            <div id="section-1"><img src="/adam-vradenburg-GA09PKfRIQY-unsplash.jpg" alt="" /></div>
          </div>
          <div className="image--01">
            <div id="section-2"><img src="/dianne-styles-nNuYURanW0M-unsplash.jpg" alt="" /></div>
          </div>
          <div className="image--01">
            <div id="section-3"><img src="/vlad-shapochnikov-A--cz3cxstI-unsplash.jpg" alt="" /></div>
          </div>
        </div>
      <br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
    </>
  );
}

export default ScrollComponent;

準備と設定

import ...
ReactのHook (useRef)、GSAPコア (gsap)、React連携 (@gsap/reactuseGSAP)、そしてスクロール機能 (ScrollTrigger) をインポートする。

gsap.registerPlugin(ScrollTrigger);
GSAPの機能を拡張するScrollTriggerプラグインを使用可能にするために登録する。

const container = useRef(null);
アニメーションの対象要素全体(.animation-container)への参照を保持する。

アニメーションロジック (useGSAP)

let mm = gsap.matchMedia();
gsap.matchMedia() を初期化する。これにより、CSSのメディアクエリのように画面幅に基づいてアニメーションの変更ができる。

const targets = gsap.utils.toArray('.image--01', container.current);
コンテナ内のクラス名が .image--01 の要素すべてを配列として取得して各要素に対してアニメーションを設定する。

targets.forEach(target => { ... });
取得した各.image--01要素に対してアニメーションとイベントを個別に設定するループ。

スクロール連動クリップパスアニメーション

.image--01要素の最初の子要素(IDを持つdiv、例: #section-1)に対してclip-path アニメーションが設定されています。clip-path の変形は、CSSカスタムプロパティ(--p1-x, --p1-y など)を GSAP でアニメーションさせることで実現する。

const tl = gsap.timeline({ scrollTrigger: { ... } });
タイムラインを作成し、それをScrollTriggerに連動させる。

trigger: target
アニメーションの開始・終了の基準となる要素を、現在の .image--01 要素自身に設定する。

start: 'top bottom', end: 'bottom top'
.image--01 要素が画面下端から出現し始めた時にアニメーションを開始して画面上端を通り過ぎた時に終了するように設定する。

scrub: 1
スクロール位置とアニメーションの進行度を連動さる。scrub: 1 は、スクロールが止まってから1秒間遅れてアニメーションが追いつく(スムーズな遅延効果)ことを意味する。

mm.add('(min-width: 769px)', () => { ... });
画面幅が 769px 以上の場合、このクリップパスアニメーションを適用する。

mm.add('(max-width: 768px)', () => { ... });
画面幅が 768px 以下の場合、このクリップパスアニメーションを適用する。

画像のパララックス効果

clip-path アニメーションのタイムラインとは別に、画像自体(imgタグ)に対してシンプルなパララックス(視差効果)が設定されている。

gsap.to(img, { y: '-30%', ... });
img 要素全体を、スクロール中に Y 軸方向へ -30% 上に移動させる。

scrollTrigger: { ... }
これもスクロールに連動して画像が出現してから消えるまでの間にスムーズに上方向に移動し続けるパララックス効果を実現します。

JSX (レンダリング)

  • ref={container}div.animation-container の中にアニメーションのターゲットとなる複数の .image--01 要素が配置されています。
  • .image--01要素には、clip-path のターゲットとなる子 div(例: id="section-1")とその中にパララックスのターゲットとなる img が含まれています。
  • 大量の <br /> タグは、意図的にページにスクロール領域を作り出すために挿入されています。