ScrollTriggerの snapは、スクロールを止めた後、指定した進捗へスクロール位置そのものを寄せる機能です。snap: 0.25なら25%間隔、snap: [0, 0.25, 0.75, 1]ならその4点が吸着先になります。値はピクセル数ではなく、start〜endを0〜1にした割合です。
一方、scrubはスクロール位置にアニメーションを同期させます。止めた後にアニメーションだけが追いつくのか、スクロール座標も動くのかを分けると、意図しない戻りを調べやすくなります。
この記事では、GSAP 3.15.0で、不均等なTimelineのラベルへ吸着する例を作ります。まず慣性の予測を外して停止位置を確かめ、それから方向や速度の設定を変えます。
吸着先は「start+区間の長さ×進捗」で決まる
startが800px、endが2800pxなら区間は2000pxです。進捗0.25へ吸着するスクロール座標は、800 + 2000 × 0.25 = 1300pxになります。
| 進捗 | 計算 | 吸着先の座標 |
|---|---|---|
| 0 | 800+2000×0 | 800px |
| 0.25 | 800+2000×0.25 | 1300px |
| 0.75 | 800+2000×0.75 | 2300px |
| 1 | 800+2000×1 | 2800px |
「セクションごとに止めたい」場合も、snapが各セクションの上端を自動で探すわけではありません。等間隔の4点なら 1 / (4 - 1)、つまり約0.333ずつになりますが、各セクションの高さやTimelineの時間配分が違うなら、その位置に合う配列やラベルを使います。
区間自体がずれている場合は、snapの値を調整する前に、ScrollTriggerのstart・endの読み方で計算された開始・終了位置を確認してください。
数値・配列・ラベル・関数で、止まる位置の選び方が変わる
snapをオブジェクトで書くと、吸着先の指定を snapToに置き、待機や時間などを別の項目で調整できます。
| snapToの例 | 吸着先の決め方 | 使う場面 |
|---|---|---|
0.25 |
0、0.25、0.5、0.75、1の等間隔 | 進捗を均等に区切る |
[0, 0.25, 0.75, 1] |
指定した候補点から選ぶ | 不均等な区切り位置を指定する |
'labels' |
Timelineの最も近いラベルを選ぶ | 途中で止めたら、近くの完成状態へ戻す・進める |
'labelsDirectional' |
最後にスクロールした方向のラベルを選ぶ | 少し次へ進んだら、次の状態まで進めたい |
(value) => ... |
関数が返す0〜1の値を使う | 候補や計算を自分で決める |
数値・配列で最近傍を選びたいなら directional: falseを明示します。GSAP 3.15.0では、ラベルの方向は 'labels'と 'labelsDirectional'で選びます。'labels'に directional: trueを足しても、同じ意味にはなりません。また、関数を渡す場合は、その関数が選択ロジックを担当します。
次の比較では inertia: falseとして、スクロール速度から先の位置を予測させません。ラベルが0、0.25、0.75、1にあるとき、下向きに進んで0.30で止めると、近いラベルは0.25、進行方向のラベルは0.75です。近い方へ少し戻ること自体は、最近傍の設定なら正常です。
不均等なラベルへ吸着する比較サンプル
npmのimportが使えるViteなどのプロジェクトで、gsap@3.15.0をインストールします。以下の3ファイルを同じ場所へ置いてください。ファイルを直接開くのではなく、開発サーバーから表示します。
npm install gsap@3.15.0
index.htmlです。吸着先、慣性の予測、数値・配列の方向、吸着の有効・無効を切り替えられます。図の後にも通常の本文を残しています。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ScrollTrigger snapの停止位置を比べる</title>
<link rel="stylesheet" href="./snap.css">
</head>
<body>
<header>
<h1>停止位置を比べる</h1>
<p>ラベルは0%、25%、75%、100%。図の区間をスクロールし、途中で手を止めてください。</p>
<form id="controls">
<label>吸着先
<select name="mode">
<option value="labels">最も近いラベル</option>
<option value="labelsDirectional">進行方向のラベル</option>
<option value="number">数値0.25(25%間隔)</option>
<option value="array">配列0 / 0.25 / 0.75 / 1</option>
<option value="function">関数で25%間隔の最近傍</option>
</select>
</label>
<label><input type="checkbox" name="inertia">速度から行き先を予測する</label>
<label><input type="checkbox" name="directional">数値・配列では進行方向へ寄せる</label>
<label><input type="checkbox" name="enabled" checked>吸着を有効にする</label>
</form>
<p>ラベルの方向は「吸着先」で選びます。関数はこの例では最近傍固定です。</p>
</header>
<main>
<section class="snap-stage" aria-label="4つの停止位置の比較図">
<p>0 → 25 → 75 → 100%</p>
<div class="track" aria-hidden="true"><div class="dot"></div></div>
<output id="progress">静止表示</output>
</section>
<section class="after">
<h2>図の次へ進めます</h2>
<p>ラベルの時刻は0、1、3、4です。25%と75%の間は、他の区間の2倍の長さです。</p>
<p>動きを減らす設定、または画面の高さが520px未満では、図を固定せず静止表示します。</p>
</section>
</main>
<script type="module" src="./main.js"></script>
</body>
</html>
snap.cssです。CSSのスムーズスクロールを併用せず、図はJavaScriptがなくても表示されるようにしています。
* { box-sizing: border-box; }
html { scroll-behavior: auto; }
body { margin: 0; font: 17px/1.8 system-ui, sans-serif; color: #142538; background: #f6f8fb; }
header, .after { min-height: 100svh; padding: clamp(24px, 6vw, 80px); }
h1, h2 { line-height: 1.4; }
form { display: grid; gap: 12px; max-width: 36rem; }
select { display: block; max-width: 100%; font: inherit; }
.snap-stage { height: 50svh; min-height: 250px; padding: 28px; background: #dbeafe; }
.track { height: 80px; width: 100%; border-bottom: 3px solid #142538; }
.dot { width: 56px; height: 56px; border-radius: 12px; background: #1260ad; }
output { display: block; margin-top: 20px; font-variant-numeric: tabular-nums; }
.after { background: #e0f2e8; }
main.jsです。ラベルの時刻は0、1、3、4。Timeline全体は4なので、ラベルの進捗は0%、25%、75%、100%になります。
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export { ScrollTrigger };
gsap.registerPlugin(ScrollTrigger);
const stage = document.querySelector('.snap-stage');
const track = stage.querySelector('.track');
const dot = stage.querySelector('.dot');
const output = document.querySelector('#progress');
const form = document.querySelector('#controls');
let media;
function rebuild() {
media?.revert();
output.textContent = '静止表示';
media = gsap.matchMedia();
media.add('(min-height: 520px) and (prefers-reduced-motion: no-preference)', () => {
const mode = form.elements.mode.value;
const snapTo = mode === 'number' ? 0.25
: mode === 'array' ? [0, 0.25, 0.75, 1]
: mode === 'function' ? (value) => gsap.utils.clamp(0, 1, Math.round(value * 4) / 4)
: mode;
const distance = () => Math.max(0, track.clientWidth - dot.offsetWidth);
const timeline = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
id: 'snap-demo',
trigger: stage,
start: 'top top',
end: () => '+=' + window.innerHeight * 2,
pin: true,
scrub: true,
invalidateOnRefresh: true,
snap: form.elements.enabled.checked ? {
snapTo,
inertia: form.elements.inertia.checked,
directional: form.elements.directional.checked,
delay: 0.2,
duration: 0.35,
ease: 'power1.inOut',
} : false,
onUpdate(self) {
output.textContent = 'スクロール進捗 ' + (self.progress * 100).toFixed(1) + '%';
},
},
});
timeline.addLabel('start', 0)
.to(dot, { x: () => distance() * 0.25, duration: 1 })
.addLabel('first', 1)
.to(dot, { x: () => distance() * 0.75, rotation: 180, duration: 2 })
.addLabel('second', 3)
.to(dot, { x: distance, backgroundColor: '#14814f', duration: 1 })
.addLabel('end', 4);
return () => {
// この図ではdotのtransformと背景色を専用で使う。
dot.style.removeProperty('transform');
dot.style.removeProperty('background-color');
output.textContent = '静止表示';
};
});
ScrollTrigger.refresh();
}
const refresh = () => ScrollTrigger.refresh();
form.addEventListener('change', rebuild);
window.addEventListener('load', refresh, { once: true });
rebuild();
// SPAでは、この画面を破棄するときに呼ぶ。
export function dispose() {
form.removeEventListener('change', rebuild);
window.removeEventListener('load', refresh);
media.revert();
}
図の固定は pin: true、アニメーションとの同期は scrub: trueが担当しています。ここでは数値scrubによる追従を加えず、snapでスクロール位置が動くところを見やすくしました。scrub自体の調整は、scrubでスクロールとアニメーションを同期する方法へ分けています。
画面の高さが520px未満、または「動きを減らす」設定では、Timelineと固定を解除します。通常のページはブラウザが破棄しますが、SPAで同じ画面を取り外す場合は、exportした dispose()を呼んでください。
delay・duration・ease・inertiaは、別々に調整する
最初は吸着先を一つの方式に固定し、下の項目を一つずつ変えます。今回の0.2秒・0.35秒は動きを比較するための設定値で、どのページにも適した速度ではありません。
| 設定 | 役割 | 確認すること |
|---|---|---|
delay: 0.2 |
スクロール停止後、吸着の開始を待つ間隔 | 指を離す前や操作の途中に、先へ引かれる感覚がないか |
duration: 0.35 |
吸着によって移動する時間 | 短すぎて跳ぶ、長すぎて操作を待たされる、と感じないか |
duration: {min: 0.2, max: 0.6} |
計算された移動時間を範囲内に収める | 遠い点と近い点の両方で試す |
ease: 'power1.inOut' |
吸着中の加減速 | Tween側の ease: 'none'と混同しない |
inertia: false |
速度から先の行き先を予測する処理を外す | まず現在位置からの選択を確認する |
directional: false |
数値・配列で、進行方向より最近傍を選ぶ | 前の点へ戻ってよいかを決める |
inertia: falseは、端末やブラウザの慣性スクロールを止める指定ではありません。snapの吸着先の予測を外すものです。操作が続いている間や速度が残っている間など、吸着がすぐ始まらない場合もあります。
関数の valueも、慣性を有効にした場合は速度を考慮した候補値です。現在のスクロール進捗がそのまま渡ると決めつけず、まず inertia: falseで関数の戻り値を確かめます。今回の関数は0〜1へ収めた25%間隔の最近傍を返します。
戻る・止まらない・位置がずれるときの確認表
| 症状 | 最初に確認するところ |
|---|---|
| 少し進んだのに前へ戻る | labelsや最近傍指定では正常な場合がある。次のラベルへ進めたいなら labelsDirectionalと比較 |
| 予想より先へ進む | inertia: falseで速度の予測を外す。数値・配列のdirectionalも分けて確認 |
| ラベル以外で止まる | 数値の等間隔指定とラベルの時刻比が一致しているか。animation.labelsと全durationを確認 |
| 吸着が始まらない | 現在位置がstart〜end内か。操作が続いていないか。サンプルでは吸着チェック、画面高、動きを減らす設定も確認 |
| 最後の点まで届かない | endが実際にスクロールできる範囲内か。末尾の余白やpinの区間を確認 |
| レイアウト変更後にだけずれる | 高さが確定してから ScrollTrigger.refresh()。updateだけでは区間を再計算しない |
| 横移動の内側でsnapが効かない | containerAnimationを持つ子のScrollTriggerではpin・snap非対応。親側のスクロール区間へ設定する |
| 二方向に引かれる・揺れる | 同じスクロール領域を複数のsnapが操作していないか。CSS scroll-snapや別のスクロール制御も一度外して切り分ける |
サンプルの値は、ブラウザのコンソールで次のように確認できます。計算後のstart・end、現在の進捗、ラベルの時刻を並べると、吸着先の計算と画面を照合できます。
const { ScrollTrigger } = await import('/main.js');
const st = ScrollTrigger.getById('snap-demo');
if (st) {
console.log({
start: st.start,
end: st.end,
progress: st.progress,
labels: st.animation.labels,
duration: st.animation.duration(),
});
}
/main.jsは、この例を開発サーバーのルートへ置いた場合のパスです。自分の配置に合わせて変えてください。reduced motionなどでこのScrollTriggerを作っていなければ、stは取得できません。
操作を再開したときと、吸着を外したときも確かめる
吸着の途中にユーザーが再びスクロールすると、その吸着が中断されることがあります。これは、最初に決めた位置へ無理に引き戻すための機能ではありません。途中の操作を確認する場合は snap.onInterrupt、吸着の終了は snap.onCompleteで観測できます。
文字を読んでいる最中に次へ送られたり、入力欄へ移動しにくくなったりするページには、snapを加える前に内容の読み方を見直します。吸着なしでも必要な情報へ到達できる構成を残してください。
- 数値・配列・ラベル・関数で、意図した進捗へ止まる。
- 下方向だけでなく上方向からも、選んだ規則で止まる。
- 吸着中にホイールやタッチ操作を再開できる。
- 画面サイズ変更後に、区間と移動量が更新される。
- 吸着を無効にした場合や動きを減らす設定でも、図と後続の本文を読める。
- 画面の破棄後にpinの余白やScrollTriggerが残らない。
APIの仕様はScrollTriggerの公式snapリファレンスで確認できます。まず吸着先だけを決め、速度と方向の設定を後から加えると、「どの規則でその位置へ移ったか」を追いながら調整できます。