GSAP ScrollTriggerのscrubを使うと、スクロール位置とアニメーションの進捗を同期できます。
最小構成は次のとおりです。
gsap.registerPlugin(ScrollTrigger);
gsap.to(".box", {
x: 400,
ease: "none",
scrollTrigger: {
trigger: ".section",
start: "top 80%",
end: "bottom 20%",
scrub: true,
},
});
この例では、startでアニメーション進捗が0になり、endで1になります。途中で上へ戻ればアニメーションも逆再生されます。
この記事では、scrub: trueと数値の違い、スクロール距離と速度の関係、パララックス、Timeline、トラブル対処まで順番に解説します。
ScrollTriggerのscrubとは
通常のScrollTriggerは、指定位置へ到達したことをきっかけにTweenを再生します。一方、scrubを有効にすると、ScrollTriggerの進捗0〜1とアニメーションの進捗0〜1が結び付きます。
| 設定 | 動作 | 向いている表現 |
|---|---|---|
| scrubなし | start到達をきっかけに時間で再生 | フェードイン、1回再生 |
scrub: true |
スクロール位置へ直接同期 | 進捗表示、正確なフレーム同期 |
scrub: 0.5 |
0.5秒かけてスクロール位置へ追従 | パララックス、滑らかな移動 |
scrubはスクロールそのものを滑らかにする機能ではありません。スクロールバーはブラウザ本来の動きを保ち、関連付けたアニメーションのplayheadだけが追従します。
GSAPとScrollTriggerを読み込む
npmを使う場合はGSAPをインストールします。
npm install gsap
JavaScriptでGSAP本体とScrollTriggerをimportし、プラグインを登録します。
import gsap from "gsap";
import ScrollTrigger from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
CDNの場合はGSAP本体を先に、ScrollTriggerを後に読み込みます。両方のバージョンをそろえてください。
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="./app.js"></script>
scrubの基本サンプル
次のHTMLとCSSで、スクロール区間を用意します。
<section class="scrub-section">
<div class="scrub-track">
<div class="scrub-box">SCRUB</div>
</div>
</section>
.scrub-section {
min-height: 120vh;
padding: 20vh 5vw;
overflow: hidden;
}
.scrub-track {
width: 100%;
}
.scrub-box {
width: 100px;
height: 100px;
display: grid;
place-items: center;
color: #111;
background: #b9ff66;
}
JavaScriptで、sectionが指定区間を通過する間にboxを横へ移動させます。
gsap.to(".scrub-box", {
x: () => window.innerWidth - 140,
ease: "none",
scrollTrigger: {
trigger: ".scrub-section",
start: "top 80%",
end: "bottom 20%",
scrub: true,
invalidateOnRefresh: true,
markers: true,
},
});
markers: trueはstartとendを画面へ表示する開発用設定です。位置を確認できたら、本番コードでは削除します。
scrub: trueと数値の違い
scrub: trueは直接同期する
scrollTrigger: {
trigger: ".section",
start: "top bottom",
end: "bottom top",
scrub: true,
}
スクロール位置が変わるたびに、アニメーションのplayheadが対応する進捗へ移動します。ユーザーが止まれば、アニメーションも基本的にその位置で止まります。
画像シーケンスのフレーム、進捗バー、数値など、スクロール位置と厳密に対応させたい表現に向いています。
scrub: 1は1秒かけて追いつく
scrollTrigger: {
trigger: ".section",
start: "top bottom",
end: "bottom top",
scrub: 1,
}
数値は、playheadが現在のスクロール位置に対応する進捗へ追いつくまでの時間を秒で指定します。スクロールを止めた後も、最大1秒程度はアニメーションが追従することがあります。
これは単純な再生遅延ではなく、スクロールとアニメーションの間を平滑化する設定です。0.3〜1程度から試し、操作への反応と滑らかさのバランスを確認します。
スクロール距離とアニメーション速度の関係
scrub時にアニメーション全体が完了するまでのスクロール距離は、startとendの差で決まります。
scrollTrigger: {
trigger: ".section",
start: "top center",
end: "+=1000",
scrub: true,
}
この例ではstartから1,000pxスクロールする間に、アニメーションが0から1へ進みます。end: "+=2000"なら、同じアニメーションをより長い距離でゆっくり進められます。
start / endの記法を詳しく確認する場合は、ScrollTriggerの開始・終了位置の調整方法を参照してください。
単一Tweenのdurationを増やしても距離は増えない
単一Tweenにduration: 5を設定しても、scrubで使うstart〜endのスクロール距離自体は変わりません。全体を長くスクロールさせたい場合はendを調整します。
gsap.to(".box", {
x: 500,
duration: 5,
ease: "none",
scrollTrigger: {
trigger: ".section",
start: "top top",
end: "+=1500",
scrub: true,
},
});
Timeline内のdurationは区間の比率になる
Timelineへ複数のTweenを入れた場合、各durationはstart〜end全体のどれだけを使うかという比率になります。
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ".story",
start: "top top",
end: "+=2000",
scrub: true,
},
});
timeline
.to(".box", { x: 300, duration: 1 })
.to(".box", { rotation: 360, duration: 2 })
.to(".box", { scale: 1.5, duration: 1 });
合計durationは4です。最初の移動は全体の25%、回転は50%、拡大は25%のスクロール区間を使います。
ease: noneを使う理由
スクロール量と移動量を直線的に対応させたい場合はease: "none"を指定します。
通常のeaseを使うと、ScrollTriggerの進捗が等間隔でも、対象プロパティの変化量は序盤・中盤・終盤で異なります。演出として意図的に加減速させることはできますが、パララックスや進捗表示ではnoneが挙動を理解しやすい設定です。
パララックスを実装する
背景や画像をコンテナより少し大きくし、sectionを通過する間に上下へ移動させます。
<section class="parallax">
<img class="parallax__image" src="landscape.jpg" alt="" />
<h2>Scroll linked visual</h2>
</section>
.parallax {
position: relative;
min-height: 80vh;
overflow: hidden;
}
.parallax__image {
position: absolute;
inset: -15% 0;
width: 100%;
height: 130%;
object-fit: cover;
will-change: transform;
}
gsap.fromTo(
".parallax__image",
{ yPercent: -8 },
{
yPercent: 8,
ease: "none",
scrollTrigger: {
trigger: ".parallax",
start: "top bottom",
end: "bottom top",
scrub: 0.5,
},
},
);
画像が動いたときにコンテナの外側が見えないよう、画像へ移動分の余白を持たせています。大きな画像は転送量とGPUメモリを増やすため、表示サイズに合った画像を用意してください。
ページ上部にスクロール進捗バーを作る
ページ全体の進捗は、固定した要素のscaleXを0から1へ変えると実装できます。
<div class="scroll-progress" aria-hidden="true"></div>
.scroll-progress {
position: fixed;
z-index: 100;
top: 0;
left: 0;
width: 100%;
height: 4px;
transform: scaleX(0);
transform-origin: left center;
background: #b9ff66;
}
gsap.to(".scroll-progress", {
scaleX: 1,
ease: "none",
scrollTrigger: {
trigger: document.documentElement,
start: "top top",
end: "bottom bottom",
scrub: true,
},
});
幅を直接変更するより、transformのscaleXを使うとレイアウト再計算を抑えやすくなります。
scrubとpinを組み合わせる
要素を固定したままTimelineを進める場合は、同じScrollTriggerへpinとscrubを指定できます。
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ".feature",
start: "top top",
end: "+=2000",
pin: true,
scrub: 0.6,
},
});
timeline
.from(".feature__title", { autoAlpha: 0, y: 60 })
.to(".feature__visual", { rotation: 180, scale: 1.2 });
固定時の余白、ずれ、transformを持つ親要素などはscrubではなくpin側の問題です。詳しくはScrollTriggerのpinの使い方とずれ対策を参照してください。
進捗と完了をコールバックで取得する
ScrollTriggerインスタンスのprogressは0〜1です。
const tween = gsap.to(".box", {
x: 500,
ease: "none",
onUpdate() {
console.log("animation:", this.progress());
},
scrollTrigger: {
trigger: ".section",
start: "top center",
end: "+=1000",
scrub: 0.5,
onUpdate(self) {
console.log("scroll:", self.progress);
},
onScrubComplete(self) {
console.log("catch up complete:", self.progress);
},
},
});
数値scrubではスクロール停止後もTweenが追従するため、アニメーション自体の変化を監視するならTween側のonUpdateを使います。onScrubCompleteは数値scrubが追いついたときに呼ばれます。
レスポンシブと動きを減らす設定
スマートフォンで移動量を変えたり、動きを減らすOS設定に対応したりする場合はgsap.matchMedia()を使えます。
const media = gsap.matchMedia();
media.add(
{
desktop: "(min-width: 768px)",
reduceMotion: "(prefers-reduced-motion: reduce)",
},
(context) => {
const { desktop, reduceMotion } = context.conditions;
gsap.to(".parallax__image", {
yPercent: reduceMotion ? 0 : desktop ? 10 : 4,
ease: "none",
scrollTrigger: reduceMotion
? undefined
: {
trigger: ".parallax",
start: "top bottom",
end: "bottom top",
scrub: 0.5,
},
});
},
);
必須情報を動きの途中だけに置かず、アニメーションを無効にしても読めるHTMLとCSSを先に作ります。
scrubがずれる・動かないときの確認
ScrollTriggerを登録していない
GSAP本体を読み込んだだけではScrollTriggerを使えません。importまたはscript読み込み後にgsap.registerPlugin(ScrollTrigger)を実行します。
startとendの距離が短い
数十pxの間で大きく動かすと、スクロール操作に対して変化が急になります。markersで区間を確認し、必要ならend: "+=1000"のように広げます。
要素の高さが後から変わる
画像、Webフォント、アコーディオンなどが後からレイアウトを変えると、最初に計算したstartとendが合わなくなることがあります。画像サイズをHTMLで確保し、必要な変更後にScrollTrigger.refresh()を呼びます。
関数値がリサイズ後に古い
画面幅から移動量を計算する場合は関数値とinvalidateOnRefresh: trueを使います。
gsap.to(".box", {
x: () => window.innerWidth - 140,
scrollTrigger: {
trigger: ".section",
scrub: true,
invalidateOnRefresh: true,
},
});
親要素のoverflowで見切れる
移動対象の親にoverflow: hiddenがあると、範囲外へ出た部分は見えません。パララックスでは意図した指定ですが、横移動では親要素の幅とoverflowを確認します。
パフォーマンスと後片付け
スクロール中に毎フレーム変化するプロパティは、可能ならtransformとopacityを中心にします。大量要素のwidth、height、top、leftを変えると、レイアウト計算が増えやすくなります。
SPAやコンポーネントで画面を破棄する場合は、作成したTweenとScrollTriggerを片付けます。gsap.context()やgsap.matchMedia()を使うと、関連するアニメーションをまとめてrevertできます。
const context = gsap.context(() => {
gsap.to(".box", {
x: 400,
scrollTrigger: {
trigger: ".section",
scrub: true,
},
});
}, ".page");
// 画面破棄時
context.revert();
Tweenそのものの基本、再生制御、Timelineの組み方はGSAP Tweenの使い方で解説しています。
よくある質問
scrub: trueとscrub: 0は同じですか?
直接同期させる意図はscrub: trueと明示するのが分かりやすい書き方です。平滑化したい場合だけ0より大きい秒数を指定します。
scrubとtoggleActionsは同時に使いますか?
scrubはスクロール位置がanimation progressを直接制御するため、通常はtoggleActionsによるplay、pause、reverseの制御を必要としません。到達時に一度再生する表現ならscrubを外してtoggleActionsを使います。
スクロールを止めても動き続けるのはなぜですか?
数値scrubがスクロール位置へ追いついているためです。追従をなくしたいならscrub: true、短くしたいなら数値を小さくします。
パララックスにpinは必要ですか?
通常の背景・画像パララックスには必須ではありません。sectionがviewportを通過する間に画像を少し移動するだけならscrubだけで実装できます。要素を一定区間固定する演出だけpinを追加します。
まとめ
ScrollTriggerのscrubは、start〜end間のスクロール進捗をアニメーション進捗へ対応させる設定です。
直接同期はscrub: true、滑らかに追従させる場合はscrub: 0.5などの数値を使います。全体の速さは単一Tweenのdurationではなく、startとendのスクロール距離で調整してください。
最初はmarkersを表示し、ease: "none"と十分なスクロール距離で挙動を確認します。その後、パララックス、Timeline、pin、レスポンシブ対応を一つずつ追加すると原因を切り分けやすくなります。