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でスクロールに連動させる話をします。
Table of Contents
ここ、意外と知られていないので先に書いておきます。
GSAPは2024年秋にWebflowに買収され、2025年4月29日から、すべてのプラグインが無料になりました。商用利用も含めてです。
以前は「Club GreenSock」という有料会員向けだったプラグインがいくつかあって、ScrollSmootherやSplitTextはそこに入っていました。それが全部、無料で使えます。
| 以前 | 現在 | |
|---|---|---|
| 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: 1 | 1秒かけて遅れて追いつく。慣性が付く |
スクロール連動でやりたいことは、たいていscrub: trueかscrub: 1です。
そしてscrub: 1が、前回書いたeasing.damp3と同じ役割を果たします。指を離してもぬるっと追いつく、あの手触りです。自前だと毎フレームの補間コードを書く必要がありましたが、GSAPでは数字を1つ変えるだけです。
startとendの読み方
ここが最初に詰まるポイントなので、分解しておきます。
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>;
}
useGSAPはuseEffectのGSAP版だと思ってください。アンマウント時にアニメーションを自動で片付けてくれるのが本体の価値です。useEffectで自分で書くと、Strict Modeの二重実行でScrollTriggerが重複登録され、動きがおかしくなります。
なお、R3Fのframeloop="demand"とは相性が悪いです。GSAPが値を更新しても、R3Fは「変化があった」と気づかないので描画されません。GSAPを使うならframeloopは既定のままにしておくのが無難です。
慣性スクロールは Lenis と組み合わせるのが定番
「スクロールがぬるっと遅れて付いてくる」あの挙動を足したいとき、選択肢は大きく2つあります。
| Lenis | GSAP ScrollSmoother | |
|---|---|---|
| 提供元 | Darkroom Engineering | GSAP(2025年4月から無料) |
| サイズ | 約3KB | GSAP本体+プラグイン |
| 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/reactのReactLenisコンポーネントが用意されています。
Three.jsのCanvasはどうなるか
Canvasをposition: fixedで背景に敷いている場合、Lenisは何も影響しません。 Lenisが動かしているのはページのコンテンツ側で、固定要素は対象外だからです。
前回の記事で書いた「固定Canvas × ページスクロール」の構成に、そのまま足せます。3D側はuseFrameやgsap.tickerの中で普通に描き続けるだけです。
よくあるつまずき
| 症状 | 原因 | 対処 |
|---|---|---|
| スクロールしても動かない(エラーも出ない) | gsap.registerPlugin(ScrollTrigger)を書いていない | 登録する。これが最頻出 |
| 値は変わっているのに画面が変わらない | renderer.render()が呼ばれていない | rAFループを回すかgsap.ticker.add()に乗せる |
| スクロールを戻すとアニメが戻らない | scrubがfalse(既定) | 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/lenis → lenis |
| スクロールと3Dの動きが1フレームずれる | ループが別々に回っている | gsap.tickerに両方を乗せる |
- GSAPは2025年4月から全プラグイン無料(商用含む)。SplitTextやMorphSVGも使える
- Three.jsのオブジェクトはそのままtweenできる。
cube.rotationもmaterial.colorもcamera.positionも同じ書き方 - スクロール連動の主役は
scrub。trueで直結、数値で慣性。前回のdampと同じ手触りが1行で出せる - 複数の演出はタイムラインに並べる。
scrollTriggerはタイムライン側に付ける。pin: trueで貼り付き演出 - GSAPは値を変えるだけ。描画は自分で回す。
gsap.ticker.add(render)+lagSmoothing(0)で一本化できる - Reactなら
useGSAP。後片付けを自動でやってくれるので、Strict Modeでも壊れない - 慣性スクロールはLenisが主流。DOM構造を変えずに済む。
gsap.tickerに相乗りさせて、スクロール・アニメ・描画を同じフレームに揃える
自前でscrollYを割り算していた頃と比べると、「どこで」「何を」だけ書けばよくなるのが本当に楽です。セクションを1つ足しても、他の数字を触らなくていい。この安心感は、案件で効いてきます。
- 自前でスクロール連動を書く方法は → R3Fでインタラクティブな3D|マウス追従とスクロール連動の作り方
- ReactでThree.jsを書く話は → React Three Fiber入門
- 全体のロードマップは → Three.js入門ガイド
📕 もっと手を動かして体系的に学びたい人へ
この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。
僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊