「PCでは完璧なのに、スマホ実機で見るとガタガタする…」 「リロードすると直るけど、初回アクセスだとレイアウトが崩れる…」
WEB制作の現場で GSAP (ScrollTrigger) を使ったパララックス実装中に、こんなどハマり経験はありませんか?
今回は、特定のセクション(top-recruit)のパララックス実装で発生した「スマホ特有の不具合」と、それを解決するために行った試行錯誤と最終的な解決策を共有します。
発生していた不具合
- スマホでのガタつき・ワープ:スクロールすると画像が瞬間移動したり、カクカクする。
- 2回表示:固定された画像が2回見える。
- 出る時と出ない時がある:タイミングによってアニメーションが発火しない。
- リロードすると直る:初回アクセス時はズレるが、再読み込みすると綺麗に動く。
原因と解決への道のり
1. スマホのアドレスバー伸縮問題(ガタつきの真犯人)
【原因】 スマホ(特にiOS Safari)は、スクロールに伴ってアドレスバーが出たり消えたりします。
これにより 100vh の高さがコロコロ変わり、GSAPの計算が狂ってガタつき(Jitter)が発生していました。
また、ScrollTrigger.normalizeScroll(true) は強力ですが、場合によってはスマホの慣性スクロールと喧嘩してしまいます。
【解決策】
ScrollTrigger.normalizeScroll(true)を使用しつつ、状況に応じてScrollTrigger.config({ ignoreMobileResize: true });も検討する(最終的にはnormalizeScrollが有効でした)。
2. スマホでの「画面固定(pin)」の限界
【原因】 「ワープ」や「2回表示」の主な原因は、スマホでの pin: true(画面固定)でした。
タッチ操作でのスクロールと画面固定の計算は非常に相性が悪く、特に要素の高さが変わるようなレイアウトでは計算が追いつかず、固定が外れた瞬間に要素が飛んでしまう現象が起きていました。
【解決策】 「スマホでは固定(pin)を諦める」 という判断をしました。
- PC(769px以上):
pin: trueでリッチに固定演出。 - スマホ(768px以下):
pin: falseにし、代わりに移動量(yの値)を大きくして、固定しなくてもパララックス感が出るように調整。
これで物理的にバグが起きない構造にしました。
3. 迷走と「原点回帰」
「画像の読み込みを待てばいい」「スマホで固定しなければいい」 そう考えて、Promise.all で全画像の読み込みを監視し、ResizeObserver で画面サイズの変化を常時監視するJSコードを書きました。
しかし、結果は「動かない」。
読み込み待ちの処理が厳密すぎて、どこかの画像が読み込みエラーになったり遅れたりすると、アニメーション自体が開始されないという、本末転倒な事態に陥りました。
そこでハッと気づきました。 「JSで無理やり制御しようとしすぎていないか?」
4. 結局、一番効いたのは「HTMLの基本」だった
複雑なJSを全て捨てて、コードを初期のシンプルな状態(DOMContentLoaded で発火)に戻しました。
その代わり、HTML側でたった一つ、重要な修正を行いました。
「パララックス要素の loading="lazy" を消す」
<!-- 修正前 -->
<img src="..." loading="lazy" ...>
<!-- 修正後:lazyを削除 -->
<img src="..." ...>
これだけです。 loading="lazy" があると、スクロールして画像が画面に近づくまで高さが確定せず(0px)、ScrollTriggerの計算が狂って「ワープ」や「ガタつき」を引き起こしていました。
これを消して「最初から画像を読み込ませる」ことで、ブラウザは正しい高さを認識でき、シンプルなJSでも完璧に計算が合うようになったのです。
最終的な完成コード
不具合が解消された後、クライアントの要望で「ヘッダー(採用情報ラベル)」もパララックスさせる演出を追加しました。
画像とヘッダーの移動速度を変えることで、より奥行きのある表現ができました。
document.addEventListener("DOMContentLoaded", () => {
// GSAPプラグインの登録
gsap.registerPlugin(ScrollTrigger);
// 【重要】スマホでのスクロール挙動を強制的に正常化する設定
// これによりアドレスバーの伸縮によるガタつきやズレを防ぎます
ScrollTrigger.normalizeScroll(true);
// 操作対象の取得
const section = document.querySelector(".top-recruit");
const images = document.querySelectorAll(".top-recruit__img");
// ★追加:ヘッダー要素を取得
const header = document.querySelector(".top-recruit__header");
// 要素がない場合は終了
if (!section || images.length === 0) return;
// レスポンシブ対応: リサイズ時にスタイルが崩れないように保存(ヘッダーも追加)
ScrollTrigger.saveStyles(".top-recruit__img, .top-recruit__header");
// どのような画面サイズでも("all")必ず実行する設定
ScrollTrigger.matchMedia({
"all": function () {
// タイムラインの作成
const tl = gsap.timeline({
scrollTrigger: {
trigger: section, // 対象セクション
start: "top top", // セクション上が画面上に来たら開始
end: "+=100%", // 1画面分スクロールする間実行
pin: true, // 画面固定
pinSpacing: true, // 固定時のスペース確保
scrub: 0.5, // スクロール追従の滑らかさ
anticipatePin: 1, // 固定時のガタつき防止
invalidateOnRefresh: true, // リサイズ時に再計算
}
});
// 画像のアニメーション
tl.fromTo(images,
{
// 開始位置:画面の高さの半分(50vh)下に配置
y: "50vh"
},
{
// 終了位置:画面の高さ分(100vh)上に移動完了
y: "-100vh",
ease: "none" // 等速
}
);
// ★追加:ヘッダーのアニメーション
if (header) {
tl.fromTo(header,
{ y: "0%" },
{ y: "-30vh", ease: "none" }, // 画像より少しゆっくり動かして奥行きを出す
"<" // 直前のアニメーション(画像)と同時に開始
);
}
}
});
// 最後に再計算を実行して位置を確定させる
ScrollTrigger.refresh();
});
まとめ:パララックスの教訓
loading="lazy"はパララックスの敵 動きをつける要素、特に高さを計算する要素には遅延読み込みを使ってはいけません。これが諸悪の根源でした。- JSでこねくり回す前にHTMLを見直す 「読み込み待ち処理」をJSで書くよりも、HTMLの属性一つ消す方が確実でパフォーマンスも良いことがあります。
- 演出は「安定」してから追加する 不具合が解消されて土台が安定したからこそ、最後にヘッダーのパララックス追加もスムーズに行えました。
「PCでは動くのにスマホでおかしい」という時は、まず 「その画像、遅延読み込みさせてない?」 と疑ってみてください。

