1. ホーム
  2. CSS

【CSS】will-change の使い方|アニメーションのカクつきをブラウザーへの事前通知で減らす方法

Share

スマートフォンで自分のサイトを見たとき、メニューの開閉やカードの拡大がカクついて見えたことはないでしょうか。CSS のアニメーションは、ブラウザーが「これから動く」と分かっていれば事前に準備でき、なめらかに描画できます。その事前通知を担当するのが will-change プロパティです。

will-change は見た目を変えるためのプロパティではなく、ブラウザーの最適化を手動で前倒しするためのプロパティです。そのため、とりあえず全部に付けておくと逆効果になるという、少し変わった性格を持っています。この記事では、何が起きているのか、どう書くのか、どこに書くべきか、そして付けたせいで表示が崩れたときの原因までを解説します。

アニメーションがカクつくのは「準備」が間に合っていないから

ブラウザーが画面を描くとき、要素は「レイヤー」と呼ばれる単位にまとめて描画されます。transform や opacity のアニメーションが速いと言われるのは、要素を独立したレイヤーに切り出してしまえば、その後はレイヤーの位置や不透明度を変えるだけで済み、文字やボックスを描き直す必要がないからです。

問題は、この「レイヤーに切り出す」という準備自体にコストがかかることです。ブラウザーは普段、アニメーションが始まったのを見てから準備を始めます。すると最初の数フレームだけ処理が重くなり、それが動き出しのカクつきとして見えてしまいます。

will-change は、この準備をアニメーションが始まる前に済ませておいてほしいとブラウザーに伝えるためのプロパティです。動き出しの一瞬の引っかかりがなくなるので、効くのは「なめらかさ」であって「速さ」ではない、という点は押さえておいてください。

基本の書き方

値にはこれから変化させるプロパティ名を書きます。複数あればカンマで区切ります。

style.css
/* これから transform が変わる、と伝える */
.card {
  will-change: transform;
}

/* 複数ならカンマ区切り */
.modal {
  will-change: transform, opacity;
}

注意したいのは、書いたプロパティ名がそのまま最適化の内容になるわけではないことです。ブラウザーは「transform が変わるなら、この要素は独立したレイヤーにしておこう」というように、プロパティ名から必要な準備を判断します。つまり will-change: color; のように、レイヤー化と関係のないプロパティを書いても、たいして何も起こりません。

効果が見込めるのは、主に transform・opacity・filter のような合成(コンポジット)だけで完結するプロパティです。逆に width や top のようにレイアウトの計算をやり直す必要があるプロパティは、will-change を書いても根本的には軽くなりません。この場合はプロパティ選びのほうを見直して、transform で代用できないかを検討してください。

指定できる値

プロパティ名以外に、いくつかのキーワードが用意されています。

値意味
auto初期値。特別な指示を出さず、ブラウザーの通常の判断にまかせる
transform などのプロパティ名そのプロパティがこれから変化することを伝える。カンマ区切りで複数指定できる
scroll-positionスクロール位置がこれから動くことを伝える。画面外のスクロール内容をあらかじめ描いておくといった準備につながる
contents要素の中身が頻繁に変わることを伝える。描画結果をキャッシュしても無駄になる、という意味になる

will-change の値に will-change 自身や unset・initial といったキーワードを書くことはできません。また、実際に用意される最適化の内容はブラウザーごとに違い、仕様でも実装に任せられています。「こう書けば必ずレイヤーになる」と決めつけないほうが安全です。

「動く直前」に付けるのがコツ

will-change の使い方で一番大事なのは、付けるタイミングです。要素にずっと付けっぱなしにするのではなく、動きそうになった時点で付けて、動き終わったら外すのが理想とされています。

CSS だけで実現するなら、ひとつ外側の要素のホバーで付けるのが定番の手です。カードそのものにマウスが乗るより少し前、親の領域に入った時点で準備が走るので、拡大が始まるころには準備が終わっています。下のデモの CSS を見てください。.gallery:hover .thumb__body で will-change を付け、拡大自体は .thumb:hover で行っています。

<div class="gallery">
  <figure class="thumb">
    <div class="thumb__body">サムネイル 1</div>
  </figure>
  <figure class="thumb">
    <div class="thumb__body">サムネイル 2</div>
  </figure>
  <figure class="thumb">
    <div class="thumb__body">サムネイル 3</div>
  </figure>
</div>
.gallery {
  display: flex;
  gap: 12px;
  padding: 24px;
}

.thumb {
  margin: 0;
  flex: 1;
}

.thumb__body {
  display: grid;
  place-items: center;
  height: 110px;
  border-radius: 8px;
  background: #1a73e8;
  color: #fff;
  font-weight: bold;

  /* 実際に動かすのは transform だけに絞る */
  transition: transform 0.25s ease;
}

/* ポイント: ホバーしたときではなく、
   その少し手前(親の gallery に入った時点)で準備させる */
.gallery:hover .thumb__body {
  will-change: transform;
}

/* 拡大そのものは各サムネイルのホバーで行う */
.thumb:hover .thumb__body {
  transform: scale(1.12);
}
Preview

見た目の結果は will-change が無くても同じです。違うのは、マウスがギャラリーに入った瞬間に準備が済んでいるので、拡大の 1 フレーム目から滑らかに動き出すという点だけです。そしてマウスがギャラリーから離れれば will-change も自動的に外れるので、外し忘れも起きません。

JavaScript で付け外しする

スクロールに連動する演出のように、CSS だけでは「直前」を表現できない場合は JavaScript で付け外しします。アニメーションの終了は transitionend イベントで受け取れるので、そこで外すときれいに収まります。

main.js
const panel = document.querySelector('.panel');
const button = document.querySelector('.panel-toggle');

// 押される「かもしれない」段階で準備しておく
button.addEventListener('pointerenter', () => {
  panel.style.willChange = 'transform';
});

// 押さずに離れたら、準備を取り消す
button.addEventListener('pointerleave', () => {
  if (!panel.classList.contains('is-open')) {
    panel.style.willChange = 'auto';
  }
});

button.addEventListener('click', () => {
  panel.classList.toggle('is-open');
});

// アニメーションが終わったら外す
panel.addEventListener('transitionend', (event) => {
  if (event.propertyName === 'transform') {
    panel.style.willChange = 'auto';
  }
});

外すときは will-change: auto; に戻すか、インラインスタイルであれば空文字列を代入してプロパティごと削除します。JavaScript 側で付けてすぐ動かす書き方だと、同じフレーム内で処理されて準備の効果が出ないことがあるため、できるだけ「動く操作」とは別のイベントで付けるのがポイントです。

全部に付けてはいけない理由

効くのなら全要素に付ければよさそうですが、これははっきりと逆効果になります。MDN でも「最後の手段として使うもの」と説明されているとおり、まずは使わずに済ませるのが前提のプロパティです。

メモリを余分に消費する

要素を独立したレイヤーに切り出すというのは、その要素を描いた画像をメモリ上に別枠で持つということです。1 つ 2 つなら問題ありませんが、リストの全項目に付けるとレイヤーの数だけメモリを消費します。メモリの少ない端末ではここが効いて、かえって全体の動作が重くなります。

付けっぱなしだと最適化が外れない

ブラウザーは、通常の最適化であればアニメーションが終わった時点で後片付けをします。ところが will-change をスタイルシートに書きっぱなしにすると、「まだこれから変化する」と言い続けていることになるため、後片付けが行われません。動きが終わったあともずっとレイヤーを抱えたままになります。

そもそもカクついていないなら不要

ブラウザーは will-change が無くても十分に最適化を行います。実機で見てカクついていないのなら、書く必要はありません。手順としては、まず開発者ツールのパフォーマンス計測で引っかかりを確認し、transform や opacity でアニメーションを書き直しても直らなかったときに、最後の調整として will-change を検討する、という順番になります。

will-change を付けたら表示が崩れたとき

will-change は最適化のためのプロパティですが、見た目に影響が出る場合があります。付けたとたんにレイアウトが変わって驚くことがあるので、代表的なものを押さえておきましょう。

重なり順が変わった

transform・opacity・filter など、指定すると新しいスタッキングコンテキストを作るプロパティを will-change の値に書くと、そのプロパティを実際に指定していなくてもスタッキングコンテキストが作られます。つまり will-change: transform; と書いた時点で、transform: none のままでも重なりの基準が変わります。

結果として、その要素の中の z-index は外側と比較されなくなり、「前面に出していたはずの要素が他の要素の下に隠れる」といった現象が起きます。ドロップダウンやツールチップが急に隠れたときは、祖先要素に will-change が付いていないか確認してください。

position: fixed の要素が画面に固定されなくなった

同じ理由で、will-change: transform; が付いた要素は、その子孫の position: fixed や position: absolute の配置の基準(包含ブロック)になります。ビューポートに固定していたはずのヘッダーやモーダルが、親要素にくっついて一緒にスクロールしてしまうのはこのパターンです。

この場合は、will-change を付ける位置を、固定要素を含まない内側の要素にずらすか、そもそも付けるのをやめるのが解決策になります。

文字がぼやける

要素が別レイヤーに切り出されると、文字の描画方法(サブピクセルレンダリング)が変わり、文字が少しにじんで見えることがあります。とくに背景が透明なレイヤーで起こりやすい現象です。テキストを含む要素に will-change を付けて文字の見え方が変わったときは、その要素に不透明な background-color を指定すると改善することがあります。

まとめ

will-change は、これから変化するプロパティをあらかじめブラウザーに伝えて、レイヤー化などの準備を前倒しさせるプロパティです。transform・opacity・filter のような合成だけで完結するプロパティに対して効果があり、動き出しのカクつきを抑えられます。

ただし、付けっぱなしにするとメモリを無駄に使い、ブラウザーの後片付けも止めてしまいます。動く直前に付けて、終わったら外すのが基本で、CSS だけで完結させるなら親要素のホバーで付ける書き方が扱いやすいです。スタッキングコンテキストや包含ブロックが変わって見た目が崩れることもあるので、まずは will-change なしで書き、実際にカクつきを計測してから足す、という順番を守ってください。

参考ページ