JavaScript

GSAP ScrollTriggerのsnap|吸着先・方向・戻ってしまう原因を実例で確認

ScrollTriggerのsnapを数値・配列・ラベル・関数で使い分ける方法。labelsとlabelsDirectional、directionalとinertiaの違いを、停止位置を比較できるコードと確認表で解説します。

この記事の目次
  1. 吸着先は「start+区間の長さ×進捗」で決まる
  2. 数値・配列・ラベル・関数で、止まる位置の選び方が変わる
  3. 不均等なラベルへ吸着する比較サンプル
  4. delay・duration・ease・inertiaは、別々に調整する
  5. 戻る・止まらない・位置がずれるときの確認表
  6. 操作を再開したときと、吸着を外したときも確かめる

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リファレンスで確認できます。まず吸着先だけを決め、速度と方向の設定を後から加えると、「どの規則でその位置へ移ったか」を追いながら調整できます。

スポンサーリンク