技術15分で読めます

CSSでイラストを動かす方法 ― コピペで動く基本から実務の注意点まで

イラストを1枚置いただけのページは、どうしても静かに見えます。そこに少し動きを足すと同じ素材でも印象が変わります。ぴょんと跳ねる、ふわりと浮かぶ、しっぽを振る。こうした動きはJavaScriptを一行も書かずにCSSだけで実装できます。

この記事では仕組みを土台から説明したうえで、コピペしてすぐ動く実例を5つ紹介します。さらに実務で必要になること ― アクセシビリティ、パフォーマンス、フレームワークでの書き方、動かないときの原因 ― まで扱います。HTMLとCSSは書けるがアニメーションは自信がない、という方を想定しています。

1. CSSで動かす方法は2つある

CSSで要素を動かす手段は、大きく分けて2つしかありません。transition と、@keyframes + animation です。この2つの守備範囲を最初に区別しておくと、後がずっと楽になります。

transition ― 「状態が変わったとき」の補間

transition は、要素のスタイルが変化したときに、その変化を瞬間ではなく時間をかけて行うためのプロパティです。変化のきっかけが必要で、きっかけは :hover:focus、あるいはJavaScriptによるクラスの付け外しです。

.btn {
  background-color: #ffb3c1;
  transition: background-color 0.3s ease;
}

.btn:hover {
  background-color: #ff6b8a;
}

サブプロパティは4つで、transition-property(対象、初期値 all)、transition-duration(時間、初期値 0s)、transition-timing-function(イージング、初期値 ease)、transition-delay(遅延、初期値 0s)。ショートハンドは transition: 対象 時間 イージング 遅延; の順です。duration の初期値が 0s なので、時間を書き忘れると変化は一瞬で終わり「効かない」状態になります。

@keyframes + animation ― 「勝手に動き続ける」もの

一方 @keyframes は、アニメーションの途中経過を自分で定義する方式です。きっかけがなくてもページ読み込み直後から動き、ループもできます。イラストをぴょんぴょん跳ねさせたい、ふわふわ浮かせ続けたい、といった用途はすべてこちらです。

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.illust {
  animation: fade-up 0.6s ease-out;
}

from0%to100% と同じ意味です。中間状態はパーセントで書き、0%, 100% { ... } のようにカンマでまとめられます。

使い分けの基準はシンプルで、操作に反応させたいなら transition、操作と無関係に動かし続けたいなら animation です。ホバーで拡大するだけなら @keyframes を持ち出す必要はありません。

なおMDNのCSSトランジションの解説は、auto 値との間のトランジションは仕様上推奨されずブラウザ間で挙動が異なるため避けるべきだ、と注意しています。height: auto へのアニメーションが思い通りにならないのはこれが理由です。

2. animation系プロパティの全体像

animation はショートハンドで、実際には複数のプロパティの束です。MDNのanimationリファレンスにある構成プロパティを、初期値つきで整理します。

プロパティ初期値役割
animation-namenone使う @keyframes の名前
animation-duration0s1回分の再生時間
animation-timing-functionease速度変化のカーブ
animation-delay0s開始までの待ち時間
animation-iteration-count1繰り返し回数(infinite で無限)
animation-directionnormal再生方向
animation-fill-modenone再生前後にスタイルを残すか
animation-play-staterunning再生中か一時停止か

duration と delay は順序で決まります。 ショートハンド内に時間の値を2つ書くと、1つ目が duration、2つ目が delay と解釈されます。プロパティ名ではなく出現順で決まる点に注意してください。

.a { animation: 3s 1s slide infinite; } /* 3秒かけて動く、開始は1秒後 */
.b { animation: 1s 3s slide infinite; } /* 1秒で動く、開始は3秒後 */

iteration-count は回数です。infinite を書かない限りデフォルトは1回で終わります。小数も使えるので、0.5 と書けば半分だけ再生されます。

.spin-once    { animation: spin 1s linear 1; }
.spin-forever { animation: spin 1s linear infinite; }
.spin-half    { animation: spin 1s linear 0.5; }

direction は往復の指定で、normal(毎回同じ方向)、reverse(逆再生)、alternate(行って戻る)、alternate-reverse(戻って行く)の4つ。往復運動は alternate を使うとキーフレームが半分の記述で済みます。

/* 往復を2つのキーフレームだけで表現 */
@keyframes float {
  from { transform: translateY(0); }
  to   { transform: translateY(-14px); }
}
.float { animation: float 2.4s ease-in-out infinite alternate; }

fill-mode は再生範囲外のスタイルの扱いです。forwards は最後のキーフレームの状態を保持、backwardsdelay 中に最初のキーフレームの状態を先取り、both は両方。1回きりのフェードインが最後に元へ戻ってしまう現象は、ほぼ forwards の指定漏れが原因です。

.fade-in {
  opacity: 0;
  animation: fade-up 0.6s ease-out 0.3s both;
  /* both があるので待機中も opacity:0 が保たれる */
}

play-state は再生と一時停止の切り替えです。ホバー中だけ止める制御がCSSだけで書けます。

.marquee { animation: scroll 12s linear infinite; }
.marquee:hover { animation-play-state: paused; }

キーフレームごとにイージングを変えられる

意外と知られていませんが、animation-timing-function は個々のキーフレームの中にも書けます。CSS Animations仕様には「キーフレーム内で指定されたタイミング関数は、そのキーフレームから次のキーフレームまでの区間の進行を定義する」と明記されています。跳ねる動きのように上昇と落下で速度カーブを変えたい場合に必須です。なお 100%to)に書いたものは、次のキーフレームが存在しないため無視されます。

同じ仕様には、キーフレーム内では animation-timing-function を除く animation-* プロパティも !important を付けた宣言も無視される、と書かれています。

3. なぜ top / left ではなく transform なのか

ここが、動きの「なめらかさ」を左右する最重要ポイントです。

transformの基本

transform は要素の見た目上の位置・角度・大きさ・傾きを変えるプロパティで、主な関数は4つです。

.a { transform: translate(20px, -10px); } /* 移動。translateX / translateY も可 */
.b { transform: rotate(15deg); }          /* 回転 */
.c { transform: scale(1.2); }             /* 拡大縮小。scale(1.2, 0.8) で縦横別 */
.d { transform: skew(10deg, 0deg); }      /* 傾斜 */

/* 複数指定は半角スペース区切り。左から順に適用される */
.combo { transform: translateY(-10px) rotate(8deg) scale(1.05); }

複数指定では順序が結果を変えますtranslateX(50px) rotate(45deg) は「右に50px動かしてから回す」、rotate(45deg) translateX(50px) は「回してから、傾いた座標系で50px動かす」となり、最終位置が異なります。

レンダリングパイプラインの話

ブラウザが画面に1フレームを描くまでには決まった工程があります。web.devのRendering Performanceはこれを「ピクセルパイプライン」と呼び、JavaScript → スタイル計算 → レイアウトペイント合成 の5段階として説明しています。

  • レイアウトは要素の大きさと位置を計算する工程。ある要素の幅が変われば周囲や子要素の位置もすべて計算し直しになる、最も重い工程です。
  • ペイントはテキスト・色・影・画像を実際のピクセルとして塗る工程。
  • 合成は塗り終わったレイヤーを正しい重なり順で画面に貼り合わせる工程です。

topleft を変えると要素の位置が変わるため、レイアウトから全部やり直しになります。background-color はレイアウトこそ不要ですがペイントは必要です。これに対し transformopacity は、合成の段階だけで完結します。すでに描き終わったレイヤーを、ずらす・回す・透過させるだけだからです。

web.devのAnimations guideは「transformopacity 以外のCSSプロパティをアニメーションに使う前に、そのプロパティがレンダリングパイプラインに与える影響を判断すること」と述べ、アニメーションを合成段階に留めるよう推奨しています。同記事ではレイアウトを引き起こす実装でフレームの50%が破棄されたのに対し、最適化版では1%に収まった例が示されています。

60fpsと10ミリ秒

よく言われる60fpsは、1秒間に60枚の絵を描くという意味です。web.devによれば1フレームの持ち時間は16.66ミリ秒ですが、ブラウザ自身のオーバーヘッドがあるため「あなたの処理はすべて10ミリ秒以内に終える必要がある」とされています。レイアウトの再計算が1回でも入れば簡単に超える短さです。だからこそ、動かすものは transformopacity に寄せるのが原則になります。

4. transform-origin ― 動きの支点をどこに置くか

transform は「どこを中心に」変形するかで印象がまるで変わります。それを決めるのが transform-origin です。

MDNによれば、通常のHTML要素での初期値は 50% 50% 0、つまり要素の中心です。ただしSVG要素では多くの場合 0 0(左上)が初期値で、ルートの <svg> 要素などは例外的に 50% 50% になります。SVGのパーツを回したときに想定外の動きをするのは、たいていこの違いが原因です。

値はキーワード(left center right top bottom)、パーセンテージ、長さで指定します。left = 0%center = 50%right = 100% という対応です。3つ目の値(Z軸オフセット)だけは長さのみで、パーセンテージを書くと宣言全体が無効になります。

.spin-center { transform-origin: center; }      /* コマのように回る */
.spin-corner { transform-origin: 0 0; }         /* 左上を軸に振り子のように回る */
.wag         { transform-origin: 90% 85%; }     /* しっぽの付け根を軸に振る */
.squash      { transform-origin: 50% 100%; }    /* 接地面を保ったまま潰れる */

しっぽを振る動きが自然に見えるかは、ほぼこの1行で決まります。中心を軸にするとしっぽ全体が平行移動して見え、生き物らしさが出ません。付け根を軸にして初めて「振っている」と認識されます。同じ理屈で、跳ねるイラストを潰す(スクワッシュ)ときは 50% 100% を指定し、足元を地面に接したままにします。

5. イージング ― 動きの性格を決めるもの

同じ移動距離・同じ時間でも、速度変化のカーブが違えば印象は別物になります。MDNのイージング関数リファレンスによると、キーワードは以下のベジェ曲線と等価です。

キーワード等価な cubic-bezier見え方
linearcubic-bezier(0, 0, 1, 1)終始一定速度。機械的
easecubic-bezier(0.25, 0.1, 0.25, 1)初期値。ゆっくり始まり鋭く加速し緩やかに止まる
ease-incubic-bezier(0.42, 0, 1, 1)じわっと始まり、最後は速いまま急停止
ease-outcubic-bezier(0, 0, 0.58, 1)勢いよく始まり、すーっと減速
ease-in-outcubic-bezier(0.42, 0, 0.58, 1)両端が緩やか。往復運動と相性が良い

目安は、登場は ease-out、退場は ease-in、往復は ease-in-out、回り続けるものは linear。回転を ease にすると1周ごとに引っかかって見えるので、無限回転は必ず linear にします。

かわいい動きの正体はオーバーシュート

cubic-bezier()X座標は0〜1に制限されますが、Y座標には制限がなく任意の数値を取れます。Yが1を超えると、要素は目標値をいったん通り越してから戻ってきます。この行き過ぎと戻りが、「弾む」「ぷるんとする」感覚を生みます。

/* 目標を超えてから戻る。ホバーで定番 */
.pop { transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1); }

/* 一度引いてから飛び出す(アンティシペーション) */
.anticipate { transition: transform 0.4s cubic-bezier(0.5, -0.5, 0.6, 1.4); }

数字の読み方は、cubic-bezier(x1, y1, x2, y2)y1 が序盤の勢い、y2 が終盤の勢いです。y2 を1.2〜1.7あたりにすると心地よい跳ね返りになり、2を超えると跳ねすぎて安っぽく見え始めます。

steps() ― パラパラ漫画にする

steps() は連続的な補間をやめ、指定回数だけカクカクと切り替える関数です。スプライトシートや、まばたきのような離散的な動きに使います。

/* 横に8コマ並んだ画像を1秒で1周 */
.sprite {
  width: 96px;
  height: 96px;
  background: url(sprite.png) 0 0 / 768px 96px no-repeat;
  animation: play 1s steps(8, end) infinite;
}
@keyframes play {
  to { background-position: -768px 0; }
}

第2引数の step-position には jump-startstart の別名)、jump-endend の別名、既定値)、jump-nonejump-both が指定できます。jump-none は最初と最後の値を保持し、jump-both は両端でジャンプします。

6. 実践コード例5つ

そのままコピーして動く形で紹介します。1つ目だけHTML全体を載せますので、残りは <style> 内とマークアップを差し替えてお使いください。

例1: ぴょんぴょん跳ねる(bounce)

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>bounce</title>
<style>
  body {
    display: grid;
    place-items: center;
    min-height: 100vh;
    margin: 0;
    background: #fff8f2;
  }
  .bounce {
    width: 160px;
    transform-origin: 50% 100%; /* 足元を軸にすると接地感が出る */
    animation: bounce 0.9s infinite;
  }
  @keyframes bounce {
    0% {
      transform: translateY(0) scale(1, 1);
      animation-timing-function: ease-out;            /* 上昇は減速しながら */
    }
    40% {
      transform: translateY(-52px) scale(0.96, 1.04); /* 頂点でわずかに縦長 */
      animation-timing-function: ease-in;             /* 落下は加速しながら */
    }
    70% {
      transform: translateY(0) scale(1.1, 0.9);       /* 着地でつぶれる */
      animation-timing-function: ease-out;
    }
    85% {
      transform: translateY(-12px) scale(1, 1);       /* 小さく跳ね返る */
      animation-timing-function: ease-in;
    }
    100% { transform: translateY(0) scale(1, 1); }
  }
</style>
</head>
<body>
  <img class="bounce" src="illust.png" alt="">
</body>
</html>

どこを変えると何が変わるか

  • translateY(-52px):跳ぶ高さ。大きいほど元気に見えますが、80pxを超えると重力が弱く感じられます。
  • 0.9s:リズム。0.5s前後で軽快な小動物、1.4s前後でのんびりした印象に。
  • scale(1.1, 0.9):着地のつぶれ具合。消すとゴム感がなくなり、ただの上下移動になります。
  • 40% の位置:上昇と落下の時間配分。下げると素早く上がってゆっくり落ちます。

例2: ふわふわ浮かぶ(float)

<div class="float-wrap">
  <img class="float" src="illust.png" alt="">
  <span class="float-shadow"></span>
</div>
.float-wrap {
  position: relative;
  display: inline-block;
  padding-bottom: 24px;
}
.float {
  width: 160px;
  display: block;
  animation: float 2.8s ease-in-out infinite alternate;
}
.float-shadow {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 96px;
  height: 14px;
  margin-left: -48px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.16);
  filter: blur(4px);
  animation: float-shadow 2.8s ease-in-out infinite alternate;
}
@keyframes float {
  from { transform: translateY(0); }
  to   { transform: translateY(-16px); }
}
@keyframes float-shadow {
  from { transform: scale(1);    opacity: 0.5; }
  to   { transform: scale(0.82); opacity: 0.28; }
}

どこを変えると何が変わるか

  • alternate を外すと、上がりきった瞬間に下へワープします。往復では必須です。
  • ease-in-outlinear にすると機械的な昇降に。浮遊感には両端の減速が要ります。
  • 影のアニメーションを消すと「浮いている」情報量が一気に減ります。高い位置ほど影は小さく薄い、という対応が効きます。
  • 2.8s:3秒前後がゆったり感の目安です。

例3: しっぽを振る(transform-origin が肝)

しっぽだけを別要素にして、付け根を支点に回転させます。

<div class="dog">
  <img class="dog-body" src="body.png" alt="">
  <img class="dog-tail" src="tail.png" alt="">
</div>
.dog { position: relative; width: 220px; }
.dog-body { width: 100%; display: block; }
.dog-tail {
  position: absolute;
  right: -18px;
  bottom: 46px;
  width: 62px;
  transform-origin: 100% 90%; /* 右下=付け根を支点にする */
  animation: wag 0.42s ease-in-out infinite alternate;
}
@keyframes wag {
  from { transform: rotate(-16deg); }
  to   { transform: rotate(18deg); }
}

どこを変えると何が変わるか

  • transform-origin:この例の心臓部です。center にすると、しっぽが振れずに全体がスライドして見えます。付け根の位置に合わせて数値を調整してください。
  • 角度の幅:±10deg で控えめ、±25deg を超えると大興奮の表現に。
  • 0.42s:速いほど嬉しそうに見えますが、0.3sを切るとブレて見えます。

例4: ハートビート(拍動)

心臓の鼓動は「ドクッ・ドクッ」と2回1組で、その後に休みが入ります。等間隔のパルスにしないのがコツです。

<div class="heart">♥</div>
.heart {
  font-size: 72px;
  color: #ff5c7a;
  width: 1em;
  line-height: 1;
  animation: heartbeat 1.2s ease-in-out infinite;
}
@keyframes heartbeat {
  0%   { transform: scale(1); }
  12%  { transform: scale(1.16); } /* 1拍目 */
  24%  { transform: scale(1); }
  36%  { transform: scale(1.09); } /* 2拍目は小さめ */
  48%  { transform: scale(1); }
  100% { transform: scale(1); }    /* 残り52%は休止 */
}

どこを変えると何が変わるか

  • 1.2s は毎分50拍に相当します。0.86sなら毎分70拍で、実際の安静時心拍に近づきます。
  • 48% から 100% までの「何もしない区間」が生命感を作ります。ここを削って全域に動きを詰めると、ただの点滅になります。
  • 2拍目を1拍目より小さくする(1.16 → 1.09)と余韻が生まれます。

例5: ホバーで反応する

こちらは transition の出番です。マウスだけでなくキーボード操作でも反応するよう :focus-visible を併記します。

<a class="card" href="#">
  <img src="illust.png" alt="">
  <span>ダウンロード</span>
</a>
.card {
  display: block;
  width: 200px;
  padding: 16px;
  border-radius: 16px;
  background: #fff;
  text-decoration: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  transition:
    transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 0.32s ease;
}
.card img { width: 100%; display: block; }
.card:hover,
.card:focus-visible {
  transform: translateY(-8px) scale(1.03);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.14);
}
.card:active {
  transform: translateY(-2px) scale(0.99);
  transition-duration: 0.08s; /* 押した瞬間は即座に反応させる */
}

どこを変えると何が変わるか

  • cubic-bezier(0.34, 1.56, 0.64, 1)1.56 が跳ね返りの強さです。1.0 なら普通の減速、2.2 ではかなり大げさになります。
  • transitionall にせず個別指定しているのは意図的です。all は将来追加したプロパティまで巻き込み、意図しないアニメーションや不要な再描画を招きます。
  • :activetransition-duration を短くすると、クリックの手応えが出ます。押した瞬間だけは待たせないのが鉄則です。

7. アクセシビリティ ― prefers-reduced-motion は必須です

これは「余裕があればやること」ではなく、動きを入れる以上は必ずセットで書くものです。

MDNのprefers-reduced-motionによれば、この機能は「ユーザーが本質的でない動きを最小限にする設定を端末で有効にしているか」を検出します。値は2つだけです。

  • no-preference ― 設定が行われていない状態。false と評価されます。
  • reduce ― 動きを減らす設定が有効。true と評価されるため、@media (prefers-reduced-motion)@media (prefers-reduced-motion: reduce) と等価です。

MDNは、大きなオブジェクトの拡大縮小やパン(平行移動)は前庭障害を持つ人に不快感を引き起こしうる、と明確に警告しています。前庭は内耳にある平衡感覚の器官で、ここに問題があると画面上の動きだけでめまい・吐き気・頭痛が起きることがあります。パララックスや大きなスケール変化は特に危険です。設定はmacOSの「視差効果を減らす」、Windowsの「アニメーション効果」などが該当します。

実装パターン1: サイト全体の安全網

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

0s ではなく 0.01ms にしているのは、animationendtransitionend イベントに依存した処理が動かなくなるのを防ぐためです。実質一瞬で終わりますが、イベントは発火します。

実装パターン2: 動きを別の表現に置き換える

動きを消すより、控えめな表現へ差し替えるほうが親切な場合もあります。MDNの例も、拡大縮小するパルスを不透明度だけのフェードへ差し替える構成です。

.illust {
  animation: bounce 0.9s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .illust {
    /* 位置は動かさず、不透明度の変化だけにする */
    animation: soft-fade 3s ease-in-out infinite;
  }
}

@keyframes soft-fade {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.72; }
}

JavaScriptでアニメーションを制御している場合は、matchMedia で同じ条件を読みます。

const mq = window.matchMedia('(prefers-reduced-motion: reduce)');

function apply() {
  document.body.classList.toggle('reduced', mq.matches);
}
apply();
mq.addEventListener('change', apply);

8. パフォーマンス ― will-change の正しい使い方

transformopacity に絞ってもなお、ブラウザに「これから動く」と予告して合成レイヤーを準備させたい場面があります。それが will-change ですが、非常に誤用の多いプロパティです。MDNのwill-changeは3つの警告を出しています。

  1. 多くの要素に適用しない。 will-change に紐づく最適化はマシンのリソースを大量に消費するため、「使いすぎはパフォーマンスを改善するどころかページを遅くする可能性がある」。
  2. 控えめに使う。 スタイルシートに直接書くとブラウザは通常よりずっと長く最適化を保持します。MDNは「変化の前後にスクリプトでオン・オフするのが良い習慣」としています。
  3. 直前に付けても遅い。 最適化には時間が要るため、「少し先に何かが変化することを予測して、それから設定する」のが正しい順序です。

つまり * { will-change: transform; } のような書き方は最悪です。全要素を個別レイヤーに昇格させ、GPUメモリを食い潰し、かえって描画を遅くします。推奨される形はJavaScriptでの付け外しです。

const el = document.querySelector('.illust');

el.addEventListener('mouseenter', () => {
  el.style.willChange = 'transform, opacity';
});
el.addEventListener('animationend', () => {
  el.style.willChange = 'auto';
});

CSSだけで完結させるなら、親要素のホバーで子に予告する書き方が現実的です。実際にアニメーションが始まる少し前に予告できます。

.card:hover .illust { will-change: transform; }
.illust { transition: transform 0.3s ease; }

なお、無限ループのアニメーションは常に動いているためブラウザが自動でレイヤーを作ります。will-change を足しても効果は薄く、メモリだけが増えることが多いです。またMDNによれば animation-fill-mode: forwards は対象プロパティを will-change に含めたかのように扱い、新しいスタッキングコンテキストを作る場合があります。重なり順がおかしくなったらここを疑ってください。

9. React / Vue での実装

React(CSS Modules)

CSS Modulesならクラス名の衝突を気にせず @keyframes を書けます。ビルドツールがキーフレーム名もローカルスコープ化してくれます。

/* BouncyImage.module.css */
.illust {
  width: 160px;
  transform-origin: 50% 100%;
  animation: bounce 0.9s ease-in-out infinite;
}

@keyframes bounce {
  0%, 100% { transform: translateY(0) scale(1, 1); }
  40%      { transform: translateY(-40px) scale(0.97, 1.03); }
  70%      { transform: translateY(0) scale(1.08, 0.92); }
}

.paused { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .illust { animation: none; }
}
// BouncyImage.jsx
import { useState } from 'react';
import styles from './BouncyImage.module.css';

export function BouncyImage({ src, alt = '' }) {
  const [paused, setPaused] = useState(false);
  const className = paused ? `${styles.illust} ${styles.paused}` : styles.illust;

  return (
    <img
      className={className}
      src={src}
      alt={alt}
      onClick={() => setPaused((p) => !p)}
    />
  );
}

動きのパラメータをpropsで調整したい場合は、キーフレーム自体はCSSに置き、数値だけCSS変数で注入する形が扱いやすいです。

<img className={styles.illust} style={{ '--dur': '0.6s', '--height': '-60px' }} src={src} alt="" />
.illust { animation: bounce var(--dur, 0.9s) ease-in-out infinite; }
@keyframes bounce {
  40% { transform: translateY(var(--height, -40px)); }
}

Vue(<style scoped><Transition>

ずっと動き続けるアニメーションは <style scoped> に素直に書けます。

<template>
  <img class="illust" :src="src" alt="">
</template>

<script setup>
defineProps({ src: String });
</script>

<style scoped>
.illust {
  width: 160px;
  animation: float 2.8s ease-in-out infinite alternate;
}
@keyframes float {
  from { transform: translateY(0); }
  to   { transform: translateY(-16px); }
}
@media (prefers-reduced-motion: reduce) {
  .illust { animation: none; }
}
</style>

要素の出入りをアニメーションさせるなら、組み込みの <Transition> を使います。Vue公式ドキュメントによれば、name プロパティを付けると v- の代わりにその名前が接頭辞になり、*-enter-from*-enter-active*-enter-to*-leave-from*-leave-active*-leave-to の6クラスが自動で付け外しされます。

<template>
  <button @click="show = !show">切り替え</button>
  <Transition name="pop">
    <img v-if="show" class="illust" src="/illust.png" alt="">
  </Transition>
</template>

<script setup>
import { ref } from 'vue';
const show = ref(true);
</script>

<style scoped>
.pop-enter-active {
  transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pop-leave-active {
  transition: all 0.2s ease-in;
}
.pop-enter-from,
.pop-leave-to {
  opacity: 0;
  transform: scale(0.8) translateY(12px);
}
</style>

入りは0.35秒でオーバーシュートさせ、抜けは0.2秒であっさり消す非対称の設計です。登場より退場を速くすると、操作がきびきび感じられます。

10. アニメーションが動かない原因トップ5

1. animation-fill-mode の指定漏れ

1回だけ再生するアニメーションが、終わった瞬間に元の見た目へ戻ってしまう。初期値が none である以上これは正常な挙動で、最終状態を保ちたいなら forwardsdelay 中の状態も固定したいなら both)が必要です。

/* NG: 表示された直後に消える */
.fade-in { opacity: 0; animation: fade 0.5s; }
/* OK */
.fade-in { opacity: 0; animation: fade 0.5s forwards; }

2. display: none の要素

display: none の要素はレンダリングされないため、transitionanimation も動きません。JSで display: none を外した直後にクラスを付けても、同じフレーム内なので変化と認識されず、一瞬で最終状態になります。

対策は opacityvisibility で隠す方法か、transition-behavior: allow-discrete@starting-style を使う方法です。MDNによれば allow-discretedisplay を離散アニメーションとして扱えるようにするもので、none から block へ変わる場合は0%の時点で block に切り替わるため、アニメーション中ずっと内容が見えます。

.modal {
  display: none;
  opacity: 0;
  transition: opacity 0.3s, display 0.3s allow-discrete;
}
.modal.is-open {
  display: block;
  opacity: 1;
}
@starting-style {
  .modal.is-open { opacity: 0; }
}

3. ベンダープレフィックスの誤解

「動かないのはプレフィックスがないからだ」と -webkit- を足す人がいますが、現在は逆です。CSS AnimationsとTransitionsはとうの昔にプレフィックスなしで実装済みで、モダンブラウザで -webkit-animation が要る場面はまずありません。

むしろ問題は、古い記事からコピーしたコードが @-webkit-keyframes だけを定義しているケースです。プレフィックスなしの animation が参照する名前のキーフレームが存在せず、何も起きません。プレフィックス付きを書くなら、必ずプレフィックスなしも併記してください。

4. infinite の書き忘れ

animation-iteration-count の初期値は 1 です。ずっと動かすつもりのループが「なぜか一瞬で終わる」場合、ほぼこれです。

/* 1回で終わる */
.spin { animation: spin 2s linear; }
/* 回り続ける */
.spin { animation: spin 2s linear infinite; }

5. 初期状態のちらつき

delay を付けたフェードインで、待機中の一瞬だけ要素が完成形で表示される現象です。delay 中は初期値 animation-fill-mode: none によって元のスタイルが適用されているためで、backwardsboth を指定すれば待機中も最初のキーフレームの状態が先取りされます。

/* NG: 待機の0.6秒間、完成形が見えてしまう */
.item { animation: fade-up 0.5s ease-out 0.6s; }
/* OK */
.item { animation: fade-up 0.5s ease-out 0.6s backwards; }

その他の見落としがちな点として、@keyframes 名のタイプミス(エラーにならず黙って無視されます)、transition を変化後のセレクタ(:hover 側)に書いてしまうミス、インライン要素には transform が効かないこと(display: inline-block にすれば解決)が挙げられます。

まとめ

  • 操作への反応は transition、動き続けるものは @keyframes + animation
  • 動かすのは原則 transformopacitytop / left はレイアウトを引き起こす
  • 1フレームの持ち時間は16.66ミリ秒、実質10ミリ秒
  • 支点を決める transform-origin が、動きの説得力を左右する
  • cubic-bezier のY座標を1より大きくすると跳ね返りが出る
  • prefers-reduced-motion: reduce への対応は必須。前庭障害への配慮を
  • will-change は控えめに、変化の少し前に

数値の感覚は、実際に動かして見るのが一番早く身につきます。Opomedoのアニメーション機能では、バウンス・ふわふわ・くるくる・しっぽふりふりなど27種類の動きをその場でプレビューし、CSS / React / Vue / jQuery のコードをそのまま取得できます(出力コードはCC0。手持ちの画像で試すこともでき、画像はブラウザ内でのみ処理されサーバーには送信されません)。カーブや時間の値を変えたときに印象がどう変わるかを確かめる場として使ってみてください。

参考リンク