iPhoneのSafariでもAndroidのChromeでも、スクロールやアニメーションがカクつく原因の9割は同じです。毎フレームLayout(配置計算)やPaint(描画)を発生させるCSSプロパティを触っていることです。対処は逆にシンプルで、動かすのはコンポジター(Compositor)だけで処理できる transform と opacity に寄せる。それだけです。この記事では実案件のパフォーマンス改善で効いたCSSテクニックを7つ、動くデモ付きで紹介します。すべて手元のスマホ実機で確認できる内容です。なお後半には、iOS 26 / Safari 26以降で変わった前提(GPUハックの終焉とメモリ管理の厳格化)も補足します。
前提: カクつきの正体は「Layout / Paint / Composite」
ブラウザの描画は大きく3段階です。Layout(どこに置くか)→ Paint(何色で塗るか)→ Composite(レイヤーを合成する)。width や left、background-image を毎フレーム変えると前半の重い工程からやり直しになります。transform と opacity なら最後の合成だけで済むため、ミドルレンジのAndroidでも60fpsを維持しやすくなります。iPhoneはGPUが強めですが、Safariは filter や position: fixed との組み合わせで独自のちらつきが出やすいので別枠の対策が必要です。
DevToolsのPerformanceパネルで「紫(Layout)・緑(Paint)」がスクロール中に連続していたら、この記事の1〜3が効きます。黄色(Script)が長い場合はJS側(rAFスロットルやpassiveリスナー)も併せて見直しましょう。
1. 位置のアニメは left / top ではなく transform で動かす
もっとも効果が大きい基本技です。left: 0 → 200px は1ピクセル動くたびにLayout→Paint→Compositeのフルコースです。transform: translateX() なら合成だけです。カルーセル、ドロワー、追従ボタン、スライドインはすべてこちらに寄せます。
/* ❌ NG: 毎フレーム Layout + Paint */
.box-ng {
left: 0;
transition: left 0.3s ease;
}
.box-ng.is-open { left: 200px; }
/* ✅ OK: 合成のみ */
.box-ok {
transform: translateX(0);
transition: transform 0.3s ease;
}
.box-ok.is-open { transform: translateX(200px); }<div id="d1-wrap" style="font-family:sans-serif;">
<p style="font-size:13px;color:#555;">ボタンを押すと2つの四角が右へ移動します。低スペック機ほど差が出ます。</p>
<div style="position:relative;height:110px;background:#f0f3f5;border-radius:8px;overflow:hidden;">
<div id="d1-ng" style="position:absolute;top:12px;left:0;width:56px;height:36px;line-height:36px;text-align:center;font-size:12px;color:#fff;background:#c2410c;border-radius:6px;">left</div>
<div id="d1-ok" style="position:absolute;top:60px;left:0;width:56px;height:36px;line-height:36px;text-align:center;font-size:12px;color:#fff;background:#3b5ba9;border-radius:6px;">transform</div>
</div>
<button id="d1-btn" style="margin-top:10px;background:#3b5ba9;color:#fff;border:0;border-radius:6px;padding:8px 20px;font-size:14px;cursor:pointer;">動かす</button>
</div>
<style>
#d1-ng { transition: left 0.6s ease; }
#d1-ok { transition: transform 0.6s ease; }
</style>
<script>
(() => {
let open = false;
const ng = document.getElementById('d1-ng');
const ok = document.getElementById('d1-ok');
document.getElementById('d1-btn').addEventListener('click', () => {
open = !open;
ng.style.left = open ? 'calc(100% - 64px)' : '0px';
ok.style.transform = open ? 'translateX(' + (ng.parentElement.clientWidth - 64) + 'px)' : 'translateX(0)';
});
})();
</script>ドロワーやモーダルのスライドインで
left/rightを使っていたら、まずここを直すのがコスパ最大です。見た目は同じでカクつきだけ減ります。
2. transition: all をやめて対象プロパティを指名する
ヘッダーの隠す/出す、ボタンのホバー、開閉パネルでやりがちなのが transition: all です。意図しないプロパティ(background や box-shadow など)の補間が走り、レイヤー再構築を誘発して初回だけガクッとなります。動かすものだけ書くのが正解です。特にスクロール連動で付け外しするヘッダーは影響が大きいので最優先で直します。
/* ❌ NG: 意図しない補間・再構築を誘発 */
.header.down {
transform: translateY(-100%);
transition: all 0.6s ease;
}
/* ✅ OK: transform に限定 */
.header.down {
transform: translateY(-100%);
transition: transform 0.6s ease;
}
.header.up {
transform: translateY(0);
transition: transform 0.6s ease;
}<div id="d2-wrap" style="font-family:sans-serif;">
<div style="position:relative;height:120px;background:#1c2733;border-radius:8px;overflow:hidden;">
<div id="d2-bar" style="position:absolute;top:0;left:0;right:0;height:36px;line-height:36px;text-align:center;font-size:13px;color:#fff;background:#3b5ba9;">ヘッダー</div>
<div style="position:absolute;top:48px;left:0;right:0;text-align:center;font-size:12px;color:#9fb0c3;">スクロールで隠す想定のバー</div>
</div>
<button id="d2-btn" style="margin-top:10px;background:#3b5ba9;color:#fff;border:0;border-radius:6px;padding:8px 20px;font-size:14px;cursor:pointer;">隠す / 出す</button>
</div>
<style>
#d2-bar { transition: transform 0.4s ease; }
#d2-bar.is-hide { transform: translateY(-110%); }
</style>
<script>
(() => {
const bar = document.getElementById('d2-bar');
document.getElementById('d2-btn').addEventListener('click', () => bar.classList.toggle('is-hide'));
})();
</script>
allの何が悪いの?→ ブラウザが「何が変わるか」を毎回調べ直すため、変わってほしくないbox-shadowやbackgroundまでアニメ対象になります。対象を指名すればコンポジターが最短経路を選べます。
3. 動く要素の filter: blur() をやめる(ぼかしはSVGに焼き付け)
背景の装飾で filter: blur(25px) を掛けたまま6枚同時移動させるような実装が、実案件で最大の重さでした。blur は毎フレーム周辺ピクセルを参照する高コストなPaintです。iPhoneでも発熱し、Androidでは一気にコマ落ちします。ぼかし自体が必要なら、CSSで毎回ぼかすのではなくSVGの feGaussianBlur で焼き付けた画像を使い、動かす側は opacity + transform だけにします。
/* ❌ NG: 動く要素に毎フレーム blur */
.mask-ng {
filter: blur(25px);
will-change: filter;
}
/* ✅ OK: filter を外し、合成のみで動かす */
.mask-ok {
opacity: 0.6;
backface-visibility: hidden;
transform: translate3d(0, 0, 0);
animation-timing-function: linear;
animation-iteration-count: infinite;
}<div id="d3-wrap" style="font-family:sans-serif;">
<p style="font-size:13px;color:#555;">2つの円が左右に動きます。左だけCSS blur付き。実機で比べると左が重いのが分かります。</p>
<div style="display:flex;gap:12px;">
<div style="flex:1;height:110px;background:#f0f3f5;border-radius:8px;overflow:hidden;position:relative;">
<div class="d3-blur" style="position:absolute;top:25px;left:10px;width:60px;height:60px;border-radius:50%;background:#4fa3c4;"></div>
<div style="position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:11px;color:#555;">blurあり(NG)</div>
</div>
<div style="flex:1;height:110px;background:#f0f3f5;border-radius:8px;overflow:hidden;position:relative;">
<div class="d3-plain" style="position:absolute;top:25px;left:10px;width:60px;height:60px;border-radius:50%;background:#3b5ba9;opacity:0.6;"></div>
<div style="position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:11px;color:#555;">blurなし(OK)</div>
</div>
</div>
</div>
<style>
@keyframes d3-move { from { transform: translateX(0); } to { transform: translateX(120px); } }
#d3-wrap .d3-blur { filter: blur(8px); animation: d3-move 1.6s ease-in-out infinite alternate; }
#d3-wrap .d3-plain { transform: translate3d(0,0,0); backface-visibility: hidden; animation: d3-move 1.6s ease-in-out infinite alternate; }
</style>ぼかしが必要なデザインなら、FigmaやSVG側で書き出した「ぼかし済み画像」を使うのが定石です。動かす側のCSSは軽いままにできます。
4. will-change は「動いている間だけ」付ける
「iOSでGPUを使うために will-change を付ける」という説明をよく見ますが、正確には「変化しそうだと事前にヒントを出す」だけのプロパティで、GPU使用の保証ではありません。常時付けっぱなしにするとGPUメモリを浪費し、かえってカクつきます。無限アニメ中だけ付ける、1pxの下線など少数要素に限って事前付与する、が正しい使い分けです。
/* ❌ NG: 常時昇格でメモリ浪費 */
.vartical { will-change: transform; }
.lead span { will-change: opacity, transform; }
/* ✅ OK: 動いている間だけ */
.vartical { contain: layout paint; backface-visibility: hidden; }
.vartical.isMoving {
will-change: transform, opacity;
animation: lineMove 10s infinite;
}
/* ✅ OK: 1pxの下線・少数要素なら事前付与は無視できるコスト */
.link span::after {
transform: scaleX(0);
transform-origin: left center;
will-change: transform;
}<div id="d4-wrap" style="font-family:sans-serif;padding:12px;">
<a href="#" id="d4-link" style="color:#3b5ba9;font-size:16px;text-decoration:none;position:relative;">ホバーで下線が伸びるリンク<span id="d4-bar"></span></a>
<p style="font-size:12px;color:#555;">下線だけに will-change: transform。初回ホバーでもカクつきません。</p>
</div>
<style>
#d4-link { display: inline-block; }
#d4-bar { display:block; height:2px; background:#3b5ba9; transform: scaleX(0); transform-origin: left center; will-change: transform; transition: transform 0.3s cubic-bezier(0.22,1,0.36,1); }
#d4-link:hover #d4-bar { transform: scaleX(1); }
</style>
<script>
document.getElementById('d4-link').addEventListener('click', (e) => e.preventDefault());
</script>
will-changeを付ければ速くなるの?→ 変化の「予告」であって速度向上の呪文ではありません。大量指定は逆効果なので、実機で重い箇所に絞って検証してから付けましょう。
5. fixed要素自体は動かさず、中の子要素を動かす(iPhone対策)
Safariで特に多いのが「position: fixed のヘッダーや追従ボタンに直接 transform / filter アニメを付けたらちらつく」現象です。fixedと変形・ぼかし・透明度の組み合わせで合成レイヤーや重なり順が変わるのが原因です。対策は、fixedの外枠は静止させたまま、中の子要素だけを動かすことです。Androidでもレイヤー再構築が減るので有効です。
/* ❌ NG: fixed 自体を変形 */
.fixed-banner-ng {
position: fixed;
bottom: 0;
transform: translateY(20px);
animation: floatY 2s ease-in-out infinite alternate;
}
/* ✅ OK: 外枠は固定、中身だけ動かす */
.fixed-banner-ok { position: fixed; bottom: 0; }
.fixed-banner-ok .inner {
transform: translate3d(0, 0, 0);
animation: floatY 2s ease-in-out infinite alternate;
}
@keyframes floatY {
from { transform: translateY(0); }
to { transform: translateY(-8px); }
}<div id="d5-wrap" style="font-family:sans-serif;">
<div style="position:relative;height:130px;background:#1c2733;border-radius:8px;overflow:hidden;">
<div style="position:absolute;top:10px;left:0;right:0;text-align:center;font-size:12px;color:#9fb0c3;">画面下に固定した想定のバー</div>
<div style="position:absolute;bottom:10px;left:12px;right:12px;background:#3b5ba9;color:#fff;border-radius:8px;font-size:13px;text-align:center;">
<div id="d5-inner" style="padding:10px;">中の子要素だけ上下(fixed外枠は静止)</div>
</div>
</div>
</div>
<style>
@keyframes d5-float { from { transform: translateY(0); } to { transform: translateY(-6px); } }
#d5-inner { transform: translate3d(0,0,0); animation: d5-float 1.4s ease-in-out infinite alternate; }
</style>「fixed+アニメでiPhoneだけちらつく」ときの切り分け順は、①対象を子要素に移す ②
will-changeを子だけに ③filterを別レイヤーに分離 ④祖先のoverflow/transformを確認、です。
6. contain / content-visibility で描画範囲を隔離する
長い一覧やLPでありがちなのが「画面外の重いセクションまで毎回レイアウト・描画し直す」問題です。contain: layout paint で波及を止め、画面外は content-visibility: auto でスキップさせます。contain-intrinsic-size で仮の高さを渡しておくと、スクロールバーのガタつきも防げます。商品一覧や画像カードが多いページで初期描画が速くなり、Androidのスクロールも滑らかになります。
.canvas__inner { contain: layout paint; }
.about .bg {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}<div id="d6-wrap" style="font-family:sans-serif;">
<p style="font-size:13px;color:#555;">30枚のカードは画面外では描画スキップ(content-visibility: auto)。スクロールが軽くなります。</p>
<div id="d6-list" style="height:180px;overflow:auto;background:#f0f3f5;border-radius:8px;padding:8px;display:grid;gap:8px;">
</div>
</div>
<style>
#d6-list .card { content-visibility: auto; contain-intrinsic-size: auto 64px; background:#fff; border-radius:6px; padding:10px; font-size:13px; color:#1c2733; contain: layout paint; }
</style>
<script>
(() => {
const list = document.getElementById('d6-list');
for (let i = 1; i <= 30; i++) {
const d = document.createElement('div');
d.className = 'card';
d.textContent = 'カード ' + i + ' — 画面外では描画スキップ';
list.appendChild(d);
}
})();
</script>高さが潰れたりガタつかない?→
contain-intrinsic-sizeで仮の高さを渡すのがポイントです。auto 500pxのように書くと、実測値を覚えて次回から使ってくれます。
7. 見えていないときはアニメを止める(電池・発熱対策も兼ねる)
画面外で回り続ける無限アニメ、メニューで隠れている裏側のアニメは、カクつきと電池消費の二重苦です。IntersectionObserver で可視判定して animation-play-state: paused に切り替え、モーション軽減設定(prefers-reduced-motion)では最初から止めます。JS無限ループ(framer-motionのrepeatなど)で等価な動きができるならCSSの @keyframes に逃がすと、メインスレッド占有も減ってAndroidで特に効きます。
.isPaused .mask { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.mask, .vartical.isMoving { animation: none; }
}// 可視判定で停止(見た目への影響なしに負荷だけ消す)
useEffect(() => {
const io = new IntersectionObserver(
([entry]) => setIsPaused(!entry.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, []);<div id="d7-wrap" style="font-family:sans-serif;">
<p style="font-size:13px;color:#555;">下のスクロール枠の中で星が見えている間だけ回転します。枠外にスクロールすると停止します。</p>
<div id="d7-scroll" style="height:170px;overflow:auto;background:#f0f3f5;border-radius:8px;padding:8px;">
<div style="height:120px;font-size:12px;color:#888;">↓ スクロールしてください</div>
<div id="d7-box" style="height:120px;background:#fff;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<div id="d7-star" style="font-size:40px;">⭐</div>
</div>
<div style="height:160px;"></div>
</div>
<p id="d7-state" style="font-size:12px;color:#3b5ba9;">状態: 再生中</p>
</div>
<style>
@keyframes d7-spin { to { transform: rotate(360deg); } }
#d7-star { animation: d7-spin 2s linear infinite; }
#d7-box.is-paused #d7-star { animation-play-state: paused; }
</style>
<script>
(() => {
const scroll = document.getElementById('d7-scroll');
const box = document.getElementById('d7-box');
const state = document.getElementById('d7-state');
const io = new IntersectionObserver(([e]) => {
box.classList.toggle('is-paused', !e.isIntersecting);
state.textContent = '状態: ' + (e.isIntersecting ? '再生中' : '停止中(負荷ゼロ)');
}, { root: scroll, threshold: 0 });
io.observe(box);
})();
</script>スクロールハンドラ自体も
{ passive: true }+ rAFスロットル化が定番です。CSSを軽くしても、JSが毎ピクセルsetStateしていたら台無しなので、方向変化時のみ更新するガードも忘れずに。Safari 26.2以降ならスクロール終了検知はタイマー推測ではなくscrollendイベントが使えます。
補足: iOS 26 / Safari 26以降で変わった3つの前提
ここまでの7つはiOSの版を問わず効く基本技ですが、iOS 26 / Safari 26以降を前提にするなら、次の3点だけ考え方が変わります。「GPUを強制すれば速くなる」時代のハックは捨てて、メモリ管理寄りの書き方に寄せるのが正解です。
1. translateZ(0) / will-change の「GPU強制ハック」は卒業する。描画のGPUプロセス分離が進み、ブラウザ側が自動でハードウェア加速を割り振るようになりました。過剰な will-change や全要素への translateZ(0) は、GPUメモリを圧迫して逆にガタつきやクラッシュの原因になります(iPadOS 26では com.apple.WebKit.GPU プロセスのメモリ逼迫によるクラッシュ報告もあります)。代わりに contain: layout paint で描画範囲を正確に伝える方が効果的です。本文の4と6をセットで使うイメージです。
/* ❌ NG: iOS 26以降は逆効果になりがち */
* { transform: translateZ(0); }
.card { will-change: transform, opacity, filter; }
/* ✅ OK: 範囲を伝えて、必要な間だけ */
.card { contain: layout paint; }
.card.is-animating { will-change: transform; }2. 非表示アニメは「停止」より「破棄」まで踏み込む。Safariはタブあたりのメモリ上限がChromeより厳しく、見えていない無限アニメがメモリと電池を食い続けます。animation-play-state: paused(本文の7)で止めるのは最低限、そのうえで画面外の重い装飾やモーダル裏のCanvasは display: none やマウント解除まで行うと、メモリ逼迫時の急なカクつき・リロードを防げます。
3. スクロール連動はJSよりCSSに逃がせる選択肢が増えた。Safari 26でスクロール駆動アニメーション(Scroll-driven Animations)とアンカー配置、26.2で scrollend、26.4でスレッド化されたスクロール駆動アニメーションが整備されました。スクロール量に応じた出現・進行表示は、JSで毎フレームCSS変数を書き換える(Paint多発のNG例)より、対応ブラウザではCSS側に任せた方がコンポジターで処理できて軽くなります。広い対応が必要な間は従来の transform / opacity への寄せ方(本文の1)+ @supports での段階採用が現実的です。
/* ✅ OK例: 対応ブラウザではスクロール駆動に任せる */
@supports (animation-timeline: scroll()) {
.reveal {
animation: reveal linear both;
animation-timeline: view();
}
}上の具体的な%や回数の話(「何%でJIT破棄」「何枚で落ちる」など)は端末・タブ数・他アプリの状況で変わるため、記事側では断定しません。実務では「重いページほど非表示要素を残さない」「containを付ける」「実機のWeb InspectorのTimelinesでメモリとフレームを確認する」の3点に落とし込むのが安全です。
チェックリスト: 実機確認の順番
最後に、リリース前の実機チェック用リストです。iPhone実機(Safari)とミドルレンジのAndroid実機(Chrome)の両方で、スクロールしながら確認してください。
確認 | NG例 | OK例 |
|---|---|---|
位置はtransformか | left / top / margin | translateX / translateY / translate3d |
transitionの対象 | all | transform, opacity を指名 |
動く要素のblur | filter: blur() | SVG焼き付け+opacity移動 |
will-change | 常時指定・translateZ(0)乱用 | アニメ中のみ+contain併用 |
fixed+アニメ | fixed自体を変形 | 子要素を変形 |
描画隔離 | 全域再レイアウト | contain / content-visibility |
不可視アニメ | 裏で回り続け | 停止+重いものは非表示・破棄 |
スクロール連動で background-image や clip-path: polygon(var(--progress)) を毎フレーム書き換える実装も、同じ理由(毎フレームPaint)で避けます。グラデーションワイプなら clip-path: inset() の固定形+ opacity / y のコンポジターアニメに置き換えるのが安全です。迷ったら「このプロパティ、毎フレームPaintしてない?」と自問するのが近道です。






