📓 memotty

【CSS】fromだけのkeyframesでインラインstyleの値へアニメーションする

やりたいこと #

SSR(サーバー側で HTML を組み立てて返す方式)でインライン style に幅を出力しているステータスバー(ポケモン図鑑の種族値バーなど)を、ページ表示時に 0% から目標幅まで左→右に伸ばしたい。

<div class="stat-bar" style="width: 27%; background-color: #ef4444"></div>
<div class="stat-bar" style="width: 43%; background-color: #eab308"></div>

要素ごとに目標幅が違うので、素朴に考えると JS で後から width を当てたくなる。

transition では発火しない #

.stat-bar { transition: width 0.6s; } と書いても何も起きない。transition は「値が変化したとき」に発火するもので、SSR では初期描画の時点で width が確定しているため、変化が存在しない。

解決: from だけの @keyframes #

@keyframesfrom だけ定義して to を省略すると、**その要素に実際に適用されている値(インライン style 含む)**が終点になり、そこへ向かって滑らかに変化する。

.stat-bar {
  animation: stat-bar-grow 0.8s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* to 未指定: インライン style の目標幅へ補間される */
@keyframes stat-bar-grow {
  from { width: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .stat-bar { animation: none; }
}

これだけで、要素ごとに異なる目標幅への伸長アニメーションが JS ゼロ・CSS 1 定義で実現できる。

ポイント #

  • to を省略した keyframe は「アニメーションを適用しなかった場合にその要素が持つ値」を終点として使う仕様。インライン style・クラス由来どちらの値でも効く
  • bothanimation-fill-mode の値で、アニメーションの開始前・終了後にキーフレームの状態を適用し続けるかを決める。both なら開始前から from の状態(width: 0)になる
  • バーごとに animation-delay で開始タイミングを少しずつずらし、順番に伸びていくように見せる演出(いわゆるスタッガー)を足す場合、遅延中のバーを幅 0 で待たせるために both が必須
  • prefers-reduced-motion は、OS の「動きを減らす(視差効果を減らす)」設定を有効にしている閲覧者を検知するメディアクエリ。ここでアニメーションを切っても、インライン style の幅がそのまま表示されるだけなので安全
  • htmx の部分更新で DOM に差し込まれた場合も、挿入のたびにアニメーションが発火する(transition 方式では不可能な挙動)

検討した代替案 #

Alpine.js の x-init で表示後に width を当てて transition を発火させる案もあった。ただしこの方式は、幅 0 の状態が一度描画されてから値を変えないとアニメーションにならない。それを保証するには requestAnimationFrame(ブラウザの次の描画タイミングに処理を予約する API)を 2 回挟むといったタイミング調整が必要で、複雑になるため見送った。

ただし「スクロールで画面内に入ったときに発火させたい」場合は CSS だけでは実現できない。要素が画面内に入ったことを検知するブラウザ API(IntersectionObserver)でアニメーション用クラスを付与する方式に移行する必要がある。

動作確認メモ #

Chrome headless の --virtual-time-budget(仮想時間を指定ミリ秒ぶん進めるオプション)では、アニメーション途中のスクリーンショットは撮れなかった。描画が落ち着いてから撮影されるため、常に完了状態が写る。

途中経過の検証には Playwright を使った。要素の getAnimations() でアニメーションオブジェクトを取得して pause() で一時停止し、currentTime を任意の時刻に固定してから getBoundingClientRect().width で実際の幅を測る。この方法なら 0ms→0%、400ms→21% のように時刻ごとの伸びを確実に記録できる。