「PCでは完璧なのに、iPhoneで見るとガタガタ震える…」
「スクロールした瞬間に背景がワープする…」
「リロードすると直るけれど、初回アクセス時だけ崩れる…」
Web制作の現場で、GSAP (ScrollTrigger) を使ったパララックス実装中に、このような現象にハマった経験はありませんか?
今回は、特定のセクションにおけるパララックス実装で発生した「スマホ特有の不具合」に対し、最終的に 「GSAP(pin)の使用をやめて、CSSの position: sticky に戻る」 ことで解決した事例をご紹介します。
当初の要件と実装
当初は、アニメーションライブラリである GSAP とそのプラグイン ScrollTrigger を採用していました。
実装したかった要件はシンプルです。
- 「採用情報セクションに到達したら背景を固定し、画像だけが下から上に流れていくパララックス演出」
PCでの動作は pin: true で画面を固定し、scrub で滑らかに画像を動かすことで完璧に動作していました。
しかし、スマホの実機テストで問題が発覚します。
スマホ実機で発生した不具合
iPhone(iOS Safari)で確認すると、以下のような挙動が頻発しました。
- ガタつき(Jitter): スクロールするたびに画面が細かく震える。
- 位置ズレ: 固定されるはずの要素が一瞬遅れてついてくる、あるいは意図しない位置に飛ぶ。
- 二重表示: 固定された要素とスクロール中の要素が重なって見える。
- 初回表示の崩れ: リロードすると直るが、ページ遷移直後だけ計算がズレる。
原因は「ブラウザの仕様」と「JS制御」の相性
調査の結果、以下の要因が複雑に絡み合っていることが分かりました。
- アドレスバーの伸縮: スクロールでアドレスバーが動くたびに
100vhの高さが変わり、GSAPが再計算(Refresh)を繰り返してしまう。 - 慣性スクロールとの競合: iOS特有の慣性スクロールと、JSによる強制的な位置固定(pin)の処理が喧嘩をしている。
- 読み込み遅延:
loading="lazy"やWebフォントの適用タイミングで高さが変わり、計算が狂う。
ScrollTrigger.normalizeScroll(true) や ignoreMobileResize の設定、画像の読み込み待機など、あらゆるJSの調整を試みましたが、コードが複雑になるばかりで根本的な解決には至りませんでした。
JSで固定するのをやめる
そこで、原点に立ち返って考え直しました。
「そもそも、画面を固定するだけなら CSS の position: sticky で良いのでは?」
GSAPの pin は、position: fixed や transform を駆使して「擬似的に固定」しています。そのため、計算がわずかでもズレるとガタつきが発生します。
一方、position: sticky はブラウザのネイティブ機能であり、OSレベルで最適化されているため、描画がガタつくことはありません。
「固定はCSS(sticky)に任せて、パララックスの移動計算だけを軽いJSで行う」
これが、最も安定する解決策でした。
解決コード(脱GSAP版)
CSS:固定は sticky に任せる
まず、CSSでセクションを固定します。これだけで「ガタつき・位置ズレ」は根本的に解消されます。
CSS
// 固定したいセクションの親要素
#js-recruit-section {
position: relative;
// 高さを持たせることでスクロール領域を作る
height: 200vh;
}
// 固定する中身
.sticky-content {
position: -webkit-sticky;
position: sticky;
top: 0;
left: 0;
height: 100vh;
overflow: hidden;
// これだけで、どんなにスクロールしても滑らかに固定される
}
JS:移動計算だけをシンプルに書く
GSAPのような高機能なライブラリは使用せず、requestAnimationFrame を使った必要最小限のコードを記述します。
JavaScript
document.addEventListener("DOMContentLoaded", () => {
const section = document.getElementById("js-recruit-section");
const parallaxImages = document.querySelectorAll(".js-parallax-img");
const parallaxTitle = document.querySelector(".js-parallax-title");
let ticking = false;
function updateParallax() {
// セクションの位置情報を取得
const rect = section.getBoundingClientRect();
const sectionTop = rect.top;
const sectionHeight = rect.height;
const windowHeight = window.innerHeight;
// セクションが画面内にある時だけ計算する(軽量化)
if (sectionTop < windowHeight && sectionTop > -sectionHeight) {
let scrollProgress = 0;
// セクションの上端が画面上部に到達してから計算開始
if (sectionTop <= 0) {
scrollProgress = Math.abs(sectionTop);
}
// 画像を動かす
parallaxImages.forEach((img) => {
const speed = parseFloat(img.getAttribute("data-speed")); // HTML側で速度調整可能に
const startOffset = 400; // 初期位置
// スクロール量に応じて transform を更新
const yPos = startOffset - scrollProgress * speed * 10;
img.style.transform = `translateY(${yPos}px)`;
});
// タイトルも動かす(視差効果)
if (parallaxTitle) {
const tSpeed = parseFloat(parallaxTitle.getAttribute("data-speed"));
const tStartOffset = 0;
const tPos = tStartOffset - scrollProgress * tSpeed * 5;
parallaxTitle.style.transform = `translateY(${tPos}px)`;
}
}
ticking = false;
}
// スクロールイベントの最適化
window.addEventListener("scroll", () => {
if (!ticking) {
window.requestAnimationFrame(updateParallax);
ticking = true;
}
}, { passive: true }); // passive: true でスクロール性能を阻害しない
// 初回実行
updateParallax();
});
技術的なポイント:
- requestAnimationFrame: スクロールイベントを間引いて、描画のタイミングに合わせて計算することでCPU負荷を抑えています。
- passive: true: スクロールイベントリスナーにこれを付与することで、ブラウザに「スクロールをブロックしない」と伝え、パフォーマンスを向上させています。
- getBoundingClientRect: 要素の現在位置を正確に取得し、画面内にあるときだけ計算を実行しています。
結果:驚くほど滑らかに
この変更によるメリットは明確でした。
- ガタつきゼロ:
position: stickyはブラウザのネイティブ挙動なので、非常に滑らかです。 - 表示崩れなし: 複雑な再計算がないため、ワープや二重表示が起きません。
- 超軽量: 巨大なライブラリを読み込まず、計算量も最小限。古い端末でも快適です。
- メンテナンス性向上: バニラJSによるシンプルな記述なので、後から誰が見ても修正が容易です。
まとめ
GSAPは素晴らしいツールですが、こと「スマホでの画面固定」に関しては、ブラウザ標準の position: sticky の安定性が勝ります。
もし今、ScrollTriggerの「スマホ実機でのガタつき」に悩み、設定の調整地獄に陥っているなら、一度 「脱GSAP(固定処理のみCSSに任せる)」 を検討してみてください。
シンプル・イズ・ベスト。これが、長い検証の末にたどり着いた最適解でした。

