Three.js × GSAPでスクロール連動の3D演出|ScrollTriggerの使い方

約19分
Three.js × GSAPでスクロール連動の3D演出|ScrollTriggerの使い方

みなさんこんにちは。フロントエンドエンジニアのしゅん(@shun_webdesign)です。

前回のR3Fでインタラクティブな3Dで、スクロール連動を自前で書きました。useFrameの中でscrollYを読んで、0〜1に正規化して、回転に流し込む。あれで動くには動きます。

ただ、実際の案件だとこうなります。

「ファーストビューで箱が正面を向いて、2セクション目で横に流れて、3セクション目で消えて、代わりにテキストが出てきて……」

これをscrollYの割り算で書こうとすると、マジックナンバーの地獄になります。「0.34を超えたらこう、0.61からはこう」みたいなコードが増えていって、セクションを1つ足した瞬間に全部の数字がズレる。僕は一度これをやって、後から自分で読めなくなりました。

そこでGSAPです。今回はThree.jsのオブジェクトを、GSAPのScrollTriggerでスクロールに連動させる話をします。

その前に:GSAPは全部無料になりました

ここ、意外と知られていないので先に書いておきます。

GSAPは2024年秋にWebflowに買収され、2025年4月29日から、すべてのプラグインが無料になりました。商用利用も含めてです。

以前は「Club GreenSock」という有料会員向けだったプラグインがいくつかあって、ScrollSmootherSplitTextはそこに入っていました。それが全部、無料で使えます。

以前現在
ScrollTrigger無料無料
ScrollSmoother有料(Club会員)無料
SplitText有料(Club会員)無料
MorphSVG / DrawSVG有料無料

「ScrollSmootherは有料だから諦めてLenisを入れた」という記憶がある方、前提が変わっています。

自前で書くのと、GSAPに任せるのの違い

まず、何が変わるのかをはっきりさせておきます。

自前(scrollYを読む)GSAP(ScrollTrigger)
書き方毎フレーム「今どこか」を計算する「どこからどこまでで、何をするか」を宣言する
基準ページ全体のスクロール率(0〜1)要素(このセクションが画面に入ったら〜)
セクション追加全部の数値を計算し直すその区間の記述を足すだけ
複数の演出if文が増えるタイムラインに並べる
向いている場面背景がゆっくり動くだけセクションごとに演出が変わるLP

要するに、「時間軸」ではなく「要素の位置」で考えられるのがGSAPの強みです。「3つ目のセクションが画面の真ん中に来たら」と書けるので、あとからセクションを増やしても他が壊れません。

逆に、背景の箱がスクロールに合わせてゆっくり回るだけなら、前回の自前実装で十分です。GSAPを入れる必要はありません。

最小構成:箱をスクロールで回す

インストールして、プラグインを登録します。

npm i gsap
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

registerPluginを忘れると、scrollTriggerオプションが黙って無視されます。エラーも出ません。「アニメーションは動くのにスクロールに連動しない」ときは、まずここを疑ってください。

そして本体です。

gsap.to(cube.rotation, {
  y: Math.PI * 2,
  scrollTrigger: {
    trigger: "#section-2",   // この要素を基準にする
    start: "top bottom",     // 要素の上端が画面の下端に来たら開始
    end: "bottom top",       // 要素の下端が画面の上端に抜けたら終了
    scrub: true,             // スクロール量と再生位置を直結する
  },
});

これだけです。#section-2が画面に入ってから抜けるまでの間に、箱が1回転します。

なぜThree.jsのオブジェクトを直接渡せるのか

cube.rotationをそのまま渡していることに、少し驚くかもしれません。

GSAPは「オブジェクトの数値プロパティを時間で変化させる」だけのライブラリです。DOM要素専用ではありません。cube.rotation{ x, y, z }を持つただのオブジェクトなので、GSAPから見ればCSSのプロパティと何も変わりません。

gsap.to(cube.position, { x: 2, y: 1 });        // 位置
gsap.to(cube.scale, { x: 1.5, y: 1.5, z: 1.5 }); // 拡大
gsap.to(material.color, { r: 1, g: 0.3, b: 0 }); // 色
gsap.to(camera.position, { z: 8 });              // カメラ

マテリアルの色もカメラも、同じ書き方で動きます。Three.js側に特別な準備は要りません。

scrubが今回の主役

scrubは「スクロール量とアニメーションの再生位置を結びつける」オプションです。ここが今回いちばん大事なところです。

設定挙動
scrub: false(既定)スクロールが開始点に達したら、自分のペースで最後まで再生する
scrub: trueスクロールにぴったり追従する。戻せば巻き戻る
scrub: 11秒かけて遅れて追いつく。慣性が付く

スクロール連動でやりたいことは、たいていscrub: truescrub: 1です。

そしてscrub: 1が、前回書いたeasing.damp3と同じ役割を果たします。指を離してもぬるっと追いつく、あの手触りです。自前だと毎フレームの補間コードを書く必要がありましたが、GSAPでは数字を1つ変えるだけです。

startendの読み方

ここが最初に詰まるポイントなので、分解しておきます。

start: "top bottom"
        ↑    ↑
        │    └── 画面(ビューポート)のどこか
        └─────── trigger要素のどこか

"top bottom"は「要素の上端が、画面の下端に来たとき」です。つまり要素が下から入ってきた瞬間。

よく使う組み合わせはこのあたりです。

記述タイミング
"top bottom"要素が下から入り始めた瞬間
"top center"要素の上端が画面中央に来たとき
"center center"要素の中心が画面中央に来たとき
"bottom top"要素が上に抜け切った瞬間

慣れるまではmarkers: trueを付けてください。開始線と終了線が画面に描かれるので、数字をいじらなくても目で調整できます。

scrollTrigger: {
  trigger: "#section-2",
  start: "top bottom",
  end: "bottom top",
  scrub: true,
  markers: true,   // 開発中だけ
}

複数の演出はタイムラインで並べる

冒頭に書いた「1セクション目でこう、2セクション目でこう」は、タイムラインで解決します。

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: "#stage",
    start: "top top",
    end: "+=3000",   // ここから3000pxぶんの間で進行する
    scrub: 1,
    pin: true,       // その間、#stage を画面に固定する
  },
});

tl.to(cube.rotation, { y: Math.PI })
  .to(cube.position, { x: 2 }, "<")        // "<" = 前のアニメと同時に開始
  .to(cube.material, { opacity: 0 })
  .to(camera.position, { z: 8 }, "-=0.3"); // 前のアニメの0.3秒手前から

scrollTriggerタイムライン側に付けるのがポイントです。個々のtweenに付ける必要はありません。タイムライン全体の再生位置が、スクロール量に対応します。

pin: trueは「進行中はその要素を画面に固定する」オプションで、セクションが貼り付いたまま中身だけが変化する、あのよく見る演出になります。

end: "+=3000"は「開始地点から3000pxスクロールする間」という意味です。演出の量が増えたらこの数字を増やす、と考えると調整しやすいです。

描画ループを一本にまとめる

ここは書いておかないと後で困る話です。

GSAPがプロパティを書き換えても、Three.jsは自分で描き直しません。 renderer.render()が呼ばれて初めて画面が変わります。

普通にrequestAnimationFrameでループを回していれば何も問題ありません。ただ、その場合はGSAPのループとThree.jsのループが別々に走っている状態になります。

GSAPには自前のティッカーがあるので、そこに乗せてしまうと1本にまとまります。

import gsap from "gsap";

function render() {
  renderer.render(scene, camera);
}

gsap.ticker.add(render);
gsap.ticker.lagSmoothing(0);

lagSmoothing(0)も入れておくのをおすすめします。GSAPは既定で「重い処理でフレームが飛んだとき、時間の進みを補正する」動きをするのですが、これがスクロール連動と噛み合わず、タブを切り替えて戻ってきたときにアニメーションが飛ぶことがあります。切っておくと素直に動きます。

React Three Fiberで使うなら

R3Fの場合は、公式の@gsap/reactが用意しているuseGSAPを使います。

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

gsap.registerPlugin(useGSAP, ScrollTrigger);

function Cube() {
  const ref = useRef();

  useGSAP(() => {
    gsap.to(ref.current.rotation, {
      y: Math.PI * 2,
      scrollTrigger: { trigger: "#section-2", scrub: true },
    });
  });

  return <mesh ref={ref}>{/* ... */}</mesh>;
}

useGSAPuseEffectのGSAP版だと思ってください。アンマウント時にアニメーションを自動で片付けてくれるのが本体の価値です。useEffectで自分で書くと、Strict Modeの二重実行でScrollTriggerが重複登録され、動きがおかしくなります。

なお、R3Fのframeloop="demand"とは相性が悪いです。GSAPが値を更新しても、R3Fは「変化があった」と気づかないので描画されません。GSAPを使うならframeloopは既定のままにしておくのが無難です。

慣性スクロールは Lenis と組み合わせるのが定番

「スクロールがぬるっと遅れて付いてくる」あの挙動を足したいとき、選択肢は大きく2つあります。

LenisGSAP ScrollSmoother
提供元Darkroom EngineeringGSAP(2025年4月から無料)
サイズ約3KBGSAP本体+プラグイン
DOM構造そのままでいい#smooth-wrapper > #smooth-content が必須
ScrollTriggerとの相性公式に連携方法が案内されている同じGSAP製なので当然良い
実際の採用こちらが主流。制作会社やAwwwards系のサイトで広く使われているGSAPで完結させたい場合

まずLenisで考えるのがおすすめです。 軽いこと以上に、既存のDOM構造を触らなくていいのが効きます。ScrollSmootherは決まった入れ子にページ全体を包む必要があるので、既存サイトに後から足すと影響範囲が広くなりがちです。

npm i lenis
import Lenis from "lenis";
import "lenis/dist/lenis.css";

const lenis = new Lenis();

// ① Lenisがスクロールしたら ScrollTrigger に教える
lenis.on("scroll", ScrollTrigger.update);

// ② Lenisの更新を GSAP のティッカーに乗せる(秒 → ミリ秒に変換)
gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
});

gsap.ticker.lagSmoothing(0);

前の節で作ったgsap.ticker.add(render)と、同じティッカーに相乗りさせるのがポイントです。

gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
  renderer.render(scene, camera);
});

こうすると「Lenisがスクロール位置を更新 → ScrollTriggerが値を計算 → GSAPがThree.jsのプロパティを書き換え → レンダリング」がすべて同じフレームの中で順番に起きます。別々のループで回すと、スクロールと3Dの動きが1フレームずれてカクついて見えることがあるので、ここは揃えておく価値があります。

lenis.raf()ミリ秒を期待するのに対し、gsap.tickerが渡してくるのはです。* 1000を忘れると、スクロールが異常に遅くなります。

パッケージ名は以前 @studio-freight/lenis でしたが、Studio Freight が Darkroom Engineering になったタイミングで lenis に変わりました。古い記事のコードをコピーするとインストールで失敗するので注意してください。Reactから使う場合は lenis/reactReactLenis コンポーネントが用意されています。

Three.jsのCanvasはどうなるか

Canvasをposition: fixedで背景に敷いている場合、Lenisは何も影響しません。 Lenisが動かしているのはページのコンテンツ側で、固定要素は対象外だからです。

前回の記事で書いた「固定Canvas × ページスクロール」の構成に、そのまま足せます。3D側はuseFramegsap.tickerの中で普通に描き続けるだけです。

よくあるつまずき

症状原因対処
スクロールしても動かない(エラーも出ない)gsap.registerPlugin(ScrollTrigger)を書いていない登録する。これが最頻出
値は変わっているのに画面が変わらないrenderer.render()が呼ばれていないrAFループを回すかgsap.ticker.add()に乗せる
スクロールを戻すとアニメが戻らないscrubfalse(既定)scrub: true にする
開始位置が思った場所とズレるstart/endの読み方markers: trueで可視化して調整
画像が読み込まれたら位置がズレたページ高さが後から変わった読み込み後にScrollTrigger.refresh()
タブを戻すとアニメが飛ぶGSAPのラグ補正gsap.ticker.lagSmoothing(0)
Reactで二重に動く・戻ると壊れるStrict Modeの二重実行useGSAPを使う(自動で片付く)
frameloop="demand"だと動かないR3Fが変化に気づかないframeloopを既定に戻す
Lenisを入れたらスクロールが激遅lenis.raf()ミリ秒gsap.tickerは秒lenis.raf(time * 1000)
Lenisがインストールできないパッケージ名が変わった@studio-freight/lenislenis
スクロールと3Dの動きが1フレームずれるループが別々に回っているgsap.tickerに両方を乗せる

まとめ

  • GSAPは2025年4月から全プラグイン無料(商用含む)。SplitTextやMorphSVGも使える
  • Three.jsのオブジェクトはそのままtweenできるcube.rotationmaterial.colorcamera.positionも同じ書き方
  • スクロール連動の主役はscrubtrueで直結、数値で慣性。前回のdampと同じ手触りが1行で出せる
  • 複数の演出はタイムラインに並べるscrollTriggerはタイムライン側に付ける。pin: trueで貼り付き演出
  • GSAPは値を変えるだけ。描画は自分で回す。 gsap.ticker.add(render)lagSmoothing(0)で一本化できる
  • ReactならuseGSAP。後片付けを自動でやってくれるので、Strict Modeでも壊れない
  • 慣性スクロールはLenisが主流。DOM構造を変えずに済む。gsap.tickerに相乗りさせて、スクロール・アニメ・描画を同じフレームに揃える

自前でscrollYを割り算していた頃と比べると、「どこで」「何を」だけ書けばよくなるのが本当に楽です。セクションを1つ足しても、他の数字を触らなくていい。この安心感は、案件で効いてきます。


📕 もっと手を動かして体系的に学びたい人へ

この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。

👉 Amazonで見る »


僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊

しゅん

しゅん

フロントエンドエンジニア / Webデザイナー。 Three.js・WebGLでのWeb表現が専門。CSS Winner / Orpetron で Site of the Day 受賞、Kindle本を2冊出しています。 音楽、映画、芸術を中心としたサブカルが大好き。 お仕事のご相談は、下記サイトの連絡先からお願いします。

← 記事一覧に戻る