パフォーマンスの高いWebアニメーション
カクつくWebアニメーションの最大の原因である、ブラウザにレイアウト(リフロー)または再描画(ペイント)を毎フレーム強制するプロパティのアニメーションを排除します。ブラウザのレンダリングは レイアウト → ペイント → コンポジット の段階で行われます。width、height、top、left、margin、padding、box-shadow、filter をアニメーションすると、毎フレームレイアウトやペイントが再実行され、メインスレッドをブロックします。transform と opacity のアニメーションは、コンポジター(多くの場合GPU)上で完全に実行され、レイアウトとペイントをスキップするため、60/120fpsで滑らかなアニメーションが実現します。
使用するタイミング
アニメーションがカクついたりフレームがドロップする場合、ホバー/スクロール効果が重く感じられる場合、サイズ/位置/シャドウをアニメーションする場合、レイアウトの変更をスムーズにアニメーションする必要がある場合(カードの並び替え、要素のコンテナ間移動)、height: auto をアニメーションする場合、またはアニメーションコードのパフォーマンスをレビューする場合に使用します。
基本ルール:transform と opacity のみをアニメーションする
すべての「高コストな」アニメーションを、同等の低コストなものにマッピングします。
| アニメーション対象(高コスト) | トリガー | 代替(低コスト) |
|---|---|---|
width / height | レイアウト + ペイント | transform: scaleX()/scaleY()(+ 真のサイズには FLIP) |
top / left / margin | レイアウト + ペイント | transform: translate() |
box-shadow | ペイント | 疑似要素でシャドウを保持し、opacity をアニメーション |
filter: blur() | ペイント(高負荷) | 2つのレイヤーを opacity でクロスフェード、または控えめに使用 |
background-position | ペイント | 子レイヤーで transform: translate() |
color / background | ペイント | 多くの場合許容範囲内、またはレイヤーのクロスフェード |
transform による位置とサイズ
/* 悪い例:毎フレームレイアウトをアニメーション */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* 良い例:コンポジターのみ */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX は内部コンテンツ(テキスト)を歪めます。歪みのない真のリサイズには FLIP を使用します。
疑似要素の opacity による低コストな box-shadow
box-shadow のアニメーションは、毎フレーム広範囲のぼかし領域を再描画します。代わりに、::after にシャドウを一度描画し、その opacity のみをアニメーションします。
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
ぼかしたシャドウは一度ラスタライズされ、ホバー時はコンポジターの opacity のみが変化するため、どのフレームレートでも滑らかです。
FLIP:レイアウト変更を低コストでアニメーション
FLIP(First, Last, Invert, Play)は、transform のみを使用してレイアウトの変更(並び替え、リサイズ、コンテナ間の移動)をアニメーションします。要素のあった位置(First)とあるべき位置(Last)を測定し、視覚的に移動していないように見える逆変換を適用し、その変換を元に戻すアニメーション(Play)を実行します。DOMは最終的な実際のレイアウトになります。モーションは純粋なコンポジター処理です。
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // DOM/レイアウトを変更
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// 使用例:グリッドの新しい位置に移動する要素をアニメーション
flip(card, () => targetContainer.appendChild(card));
Web Animations API animate() は、トランスフォームをコンポジター上で実行します。多数の要素がある場合は、すべての getBoundingClientRect() の読み取りを、すべての変更(後述のレイアウトスラッシングを参照)の前にバッチ処理します。
height: auto のアニメーション
height: auto は歴史的に補間可能ではありませんでした。最新のアプローチとフォールバック:
/* モダン(Chrome 129+/対応ブラウザ):サイズキーワードをアニメーション可能に */
.panel {
interpolate-size: allow-keywords; /* auto との間のアニメーションを有効化 */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() も使用可能:height: calc-size(auto, size); */
/* 現在の堅牢なフォールバック:CSS grid 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* 折りたたみ */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
グリッドのテクニックはトラックサイズをアニメーションし(コンポジターに優しく、広くサポートされています)、JSによる高さの測定は必要ありません。ターゲットブラウザがサポートしている場合は interpolate-size: allow-keywords / calc-size(auto, size) を使用し、クロスブラウザのデフォルトとしてグリッドのテクニックを維持します。
レイアウトスラッシングを避ける(読み取りをバッチ処理し、その後書き込み)
書き込みの後にレイアウトプロパティ(offsetWidth、getBoundingClientRect、scrollTop、getComputedStyle)を読み取ると、同期的なリフローが強制されます。ループ内で読み取りと書き込みを交互に行うと(「レイアウトスラッシング」)、1フレームあたり数十回の強制リフローが発生する可能性があります。
// 悪い例:読み取り、書き込み、読み取り、書き込み… 各イテレーションでリフローが強制される
items.forEach((el) => {
const w = el.offsetWidth; // 読み取り(レイアウトを強制)
el.style.width = w * 1.5 + 'px'; // 書き込み(レイアウトを無効化)
});
// 良い例:すべての読み取りをバッチ処理し、その後すべての書き込み
const widths = items.map((el) => el.offsetWidth); // すべての読み取り
items.forEach((el, i) => { // すべての書き込み
el.style.width = widths[i] * 1.5 + 'px';
});
フレーム同期処理の場合、requestAnimationFrame コールバック内で読み取りを行い、書き込みを適用します。fastdom のようなライブラリは、この読み取り/書き込みのスケジューリングを形式化します。
will-change:控えめに使用する
will-change: transform は、要素を事前に独自のコンポジターレイヤーに昇格させ、アニメーション開始時のひっかかりを回避します。ただし、昇格したレイヤーはそれぞれGPUメモリを消費し、過剰に使用するとパフォーマンスが低下します。
.menu { will-change: transform; } /* アニメーションしようとしている要素のみに */
ルール:アニメーションの直前に適用し(例:ホバー時/親の状態)、アイドル時には削除し(will-change: auto)、多数の要素に一律に適用せず、大規模または多数のノードに永続的に残さないようにします。単一の transform: translateZ(0) ハックは同じ昇格を行いますが、元に戻すのが難しいため、will-change を優先します。
納品と検証(スタンドアロンHTML)
パッケージ化されたヘルパー(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3は?t=Nハーネスをフリーズし、各瞬間をスクリーンショットします。scripts/contact-sheet.sh sheet.png frame-*.pngはそれらをタイル状に並べて一覧レビューを可能にします。詳細はscripts/README.mdを参照してください。
納品物は 1つの自己完結型 .html で、ブラウザで直接開くことができ、マークアップ、CSS/JSアニメーション、フリーズハーネスが1つのファイルに含まれています。このスキルでは、検証は2つの側面から行われます。フレームが正しく見えること かつ 生成が低コスト(コンポジターのみ)であることです。
出力契約:
- 1つの
.html、依存関係はCDN経由(ある場合)、アニメーションはCSSトランジション/@keyframesまたはWeb Animations APIで駆動。 - スクリーンショットが決定論的なフレームに着地するためのフリーズ機構:
- CSS
@keyframes→?t=Nでel.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'を設定。 - WAAPI / JS → アニメーションオブジェクトを保持し、
anim.pause(); anim.currentTime = N*1000を実行。
- CSS
検証ループ — フリーズ → スクリーンショット、次にジャンクのプロファイリング:
- ヘッドレスで開始/中間/終了時点(
?t=0、中間、終了)で開き、それぞれスクリーンショットを撮ります。モーションが視覚的に正しいことを確認します(scaleXによるテキストのクリップ/伸びがない、FLIPが実際のレイアウトに着地している、シャドウ/高さのトランジションが正しく見える)。 - コンポジターのみであることを確認 — これがこのスキルの要点です。以下のいずれかを実行します。
- DevTools → Performance:アニメーションを記録し、フレームごとに 紫色の「Layout」や緑色の「Paint」の帯がない ことを確認します(「Composite Layers」のみ)。
- またはヘッドレストレース:
npx playwright screenshotでビジュアルを確認し、CDP/tracingキャプチャでアニメーションウィンドウ中にLayout/Paintイベントが発生していないことをアサートします。
- FPS/「Frame Rendering Stats」オーバーレイが60のままであることを確認します。フレームドロップは、高コストなプロパティが再び混入したことを意味します。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
終了前に確認すること:
- スタンドアロンで開く — コンソールエラーがない。
- フレームごとに
transform/opacityのみがアニメーションされる。DevToolsでフレームごとのLayout/Paintが表示されない。 - 開始/中間/終了時点でスクリーンショットが正しく、
scaleXによるテキストの歪みがなく、FLIPが正しく着地している。 - 60fpsを維持。
will-changeはジャストインタイムで適用され、アイドル時には削除される(レイヤーが残らない)。 prefers-reduced-motionが尊重されている。
クイックリファレンス
| 目標 | これを行う |
|---|---|
| 要素を移動 | transform: translate() |
| 歪みなくリサイズ | transform を使用したFLIP |
| ホバー時のシャドウ | シャドウ疑似要素の opacity をアニメーション |
| コンテンツの高さに展開 | grid 0fr→1fr、または interpolate-size: allow-keywords |
| 多数の要素を移動 | getBoundingClientRect の読み取りをバッチ処理し、その後書き込み |
| スムーズなアニメーション開始 | will-change をジャストインタイムで適用、アイドル時に削除 |
| コンポジターのみの確認 | DevTools Performance:フレームごとに紫色の「Layout」/緑色の「Paint」がない |
注意点
scaleX/scaleYはテキストと子要素を引き伸ばします。コンテンツを鮮明に保つ必要がある場合はFLIPを使用します。transformのパーセンテージは、親ではなく要素自身のボックスを基準とします —left: %とは異なります。will-changeまたはtranslateZ(0)の過剰使用はレイヤーが多くなりすぎてパフォーマンスを低下させます。アニメーションするものだけを昇格させます。filterとbackdrop-filterはコンポジター関連ですが、依然として高負荷です。ブラー半径をアニメーションするのではなく、opacityクロスフェードで存在感をアニメーションします。- grid
1fr→0frのコンテンツにはoverflow: hiddenとmin-height: 0が必要です。そうしないと折りたたまれません。 - 必須ではないモーションは、常に
@media (prefers-reduced-motion: reduce)の後ろにゲートします。
参考ファイル
references/patterns-and-profiling.md— 完全な実行可能な例(アコーディオン、リストの並び替え、パララックス)、コンポジターのみのフレームを確認するDevToolsプロファイリングのチュートリアル、動きの軽減パターン、プロパティコストのチートシート。