スクロール演出用ライブラリ
固定はブラウザに任せればよかった
Appleの製品ページなどでよく見る、スクロールの途中で画面がピタッと止まって、そのまま中身だけ切り替わっていく演出。これを実装するために、GSAPのScrollTriggerを軽く補助するライブラリを作ったので、その設計の話を書いてみます。
何に困っていたか
ScrollTriggerには要素を画面に固定するpinという機能があります。1つの要素をただ固定するだけなら簡単です。難しくなるのは、固定している間もその前後の要素が一緒に止まっているように見せようとしたときでした。
pinが固定するのは指定した要素だけなので、前後の要素は普通にスクロールして先に流れていってしまい、固定中の要素との間にすき間が空きます- 複数のシーンで
pinを使うと、それぞれが管理する「まわりの余白(pin-spacing)」がシーンごとに積み重なり、リサイズや再読み込みをきっかけに固定位置がズレることがあります
ブラウザ標準のstickyに固定処理を渡す
そこで発想を変え、要素を画面に固定する処理そのものをCSSのposition: stickyに任せることにしました。ScrollTrigger側には「いつ演出を始めて、いつ終えるか」というタイミングの管理だけを担当してもらいます。このライブラリは、ブラウザとScrollTriggerが噛み合うように、sticky構造とその滞留距離ぶんの余白(padding)を計算する役目を担います。
ただし、stickyを単純に使うと今度は逆の問題が起きます。固定されるのは指定した要素だけなので、隣接する要素は普通にスクロールして消えていき、結果的に画面上にすき間が生まれてしまいます。
これを解決するために、共有コンテナを入れ子状のstickyレイヤーで包む構造にしました。
container2 ← 2番目のシーン用レイヤー
├ wrapper2 (position:sticky)
│ └ container1 ← 1番目のシーン用レイヤー
│ ├ wrapper1 (position:sticky)
│ │ └ .container__inner ← 元の共有コンテナ
│ └ padding1 ← 1番目のシーンの滞留距離ぶんの余白
└ padding2 ← 2番目のシーンの滞留距離ぶんの余白
シーンごとに1枚ずつレイヤーを重ね、DOM順で先にあるシーンほど内側に深く入れ子にする。こうすると、あるシーンが固定されている間はその外側のレイヤーごと一緒に止まるので、隣の要素だけ先にスクロールして消えていく、ということが起きなくなります。paddingが滞留距離(どれだけの間その位置に留まるか)を決め、stickyなwrapperがその間ずっと固定を保つ役割です。
「下のセクションが上のセクションにせり上がって覆う」という演出(overlap scroll)も、同じ考え方の応用で、こちらは対象の要素だけをstickyでくるみ、後続の要素は自然なスクロールに任せることで、ページ全体の高さを変えずに実現しています。
位置測定でハマったところ
この構造にすると、refresh()(各シーンのstickyの位置や余白を再計算する処理)の実装でも罠がありました。
stickyを有効にしたまま位置を測ると、既に固定されているレイヤーの影響を受けて、後続の測定値が狂う。そのため「まずstickyを一時的に無効化してすべての自然な位置を測る」→「その後まとめてstickyとpaddingを適用する」という2パス構成にしていますgetBoundingClientRectはstickyで固定中の要素だと値が変わってしまうため、offsetParentを辿って求めるdocumentTopを使い、固定中でも安定した値を取れるようにしていますwindow.innerHeightはモバイルブラウザのアドレスバーの表示/非表示で変動するため、height: 100vhの要素を一時的に生成して測る方式にしました(これはScrollTriggerの内部実装と同じアプローチです)
この構造が生む「見た目のズレ」と、その避け方
入れ子stickyの構造上、あるシーンが固定されている間、そのシーンよりDOM順で前にあるシーンの滞留距離(padding)ぶんだけ、画面上の見た目の位置と実際のスクロール量にズレ(lag)が積み重なっていきます。ページ内の別の要素に対して素のScrollTriggerを仕込みたい場合、このズレを補正した絶対座標を返すユーティリティ(resolveScrollPosition)を用意しました。
一方で、バッジのような小さな要素を画面に留めておきたいだけなら、ScrollTriggerのpin: trueは使わずposition: stickyだけで完結させる関数(createStickyPin)を別途用意しています。こちらはDOM上の静的な距離だけを見て余白を計算するため、そもそも上記のズレの影響を受けず、補正も不要です。
使ってみる
<script>タグで読み込めるビルド(global版)も用意しているので、バンドラーなしの環境でもそのまま試せます。
<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="StickyScrollTrigger.global.min.js"></script>
<script>
gsap.registerPlugin(ScrollTrigger);
const sticky = new StickyScrollTrigger('.container__inner');
gsap.fromTo(
'.scene',
{ opacity: 0 },
{
opacity: 1,
scrollTrigger: sticky.createStickyTrigger({
trigger: '.scene',
start: 'center center',
end: '+=100%',
scrub: true,
}),
},
);
sticky.refresh();
</script>
StickyScrollTrigger.global.min.jsはnpmやCDNにはまだ公開していないので、Releasesのsticky-scroll-trigger-dist.zipをダウンロードして、ご自身のサイトにアップロードしたものを読み込んでください。
まとめ
要は、固定をブラウザに、演出の進行をScrollTriggerに任せ、そのあいだの余白(padding)調整を担うのが、このライブラリの核です。複数シーンの連続固定でも、リサイズや再読み込みに対しても安定して、pinまわりの細かい調整に時間を取られることがかなり減りました。同じような演出を検討している方の参考になれば幸いです。
ソースコードは以下で公開しています。
https://github.com/kuninori-ogino/sticky-scroll-trigger
Releasesには、ビルド済みのsticky-scroll-trigger-dist.zipも置いてあるので、手元でビルドせずすぐ試したい場合はこちらをどうぞ。