CSSはモーションのために2つのツールを提供しています:トランジションと@keyframesアニメーションです。ほとんどの開発者は両方が存在することを知っていますが、最初に覚えた方を使い続けます — たいていトランジション — そして、なぜローディングスピナーがループしないのか、通知がなぜ元の位置に戻ってしまうのか不思議に思います。それぞれの目的を理解すれば、判断は難しくありません。トランジションはリアクションです:何かが状態を変え、ブラウザがその変化を滑らかにします。@keyframesアニメーションは独立したスクリプトです:自律的に動作し、必要なら無限ループし、状態トリガーは不要です。このガイドでは両方、最も重要なプロパティ、そして滑らかな60fpsアニメーションとカクつくアニメーションを分ける性能の詳細を解説します。多くのCSSを書くなら、CSSフォーマッターをブックマークする価値があります — 乱雑なショートハンドを素早くクリーンアップします。

CSSトランジション — シンプルなケース

トランジションはブラウザに伝えます:「このプロパティが変わったら、ジャンプする代わりに時間をかけて変化をアニメートして」。それだけです。最も一般的な使い方はホバーエフェクトです — 色の変化、不透明度のフェード、微妙なスケール変換。MDNのトランジションガイドが完全な仕様をカバーしていますが、重要な4つのプロパティはtransition-propertytransition-durationtransition-timing-functiontransition-delayです。

css
/* The shorthand: property duration easing delay */
.btn-primary {
  background-color: #4f46e5;
  color: #fff;
  padding: 0.625rem 1.25rem;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  transition: background-color 200ms ease, transform 150ms ease;
}

.btn-primary:hover {
  background-color: #4338ca;
  transform: translateY(-1px);
}

.btn-primary:active {
  transform: translateY(0);
}

/* Opacity fade — a card coming into view */
.notification-card {
  opacity: 0;
  transition: opacity 300ms ease-in-out;
}

.notification-card.is-visible {
  opacity: 1;
}

カンマ区切りで複数のトランジションをリストでき、それぞれ独自の持続時間とイージングを持てます — 上のボタンの例がそうです。各プロパティが独立してアニメートします。

transition: allを使わないこと。 キャッチオールとして魅力的ですが、その要素のすべてのプロパティ変更がアニメートされることを意味します — 意図しないものも含めて。各フレームでブラウザの作業量も増えます。アニメートしたい特定のプロパティをリストアップしてください。MDNのtransition-propertyドキュメントがどのプロパティがアニメート可能かを説明しています。

transition-timing-function — イージングの解説

タイミング関数はアニメーションの加速曲線を制御します — 速く始まって遅くなるか、一定速度で動くか、カスタム曲線でバウンスするか。組み込みキーワードがほとんどの状況をカバーします。

  • ease — ゆっくり始まり、加速し、終わりに向かって遅くなります。デフォルト。ほとんどのUIモーションに適しています。
  • linear — 全体を通して一定速度。スピナーやプログレスバーには良いが、その他のほとんどには不向き — 機械的に見えます。
  • ease-in — ゆっくり始まり、速く終わります。速度を上げているものの感覚。画面を離れる要素に適しています。
  • ease-out — 速く始まり、ゆっくり終わります。画面に入る要素に自然(スライドインする通知など)。
  • ease-in-out — 両端でゆっくり。ビュー内で開始・停止する要素に洗練された印象。
  • cubic-bezier(x1, y1, x2, y2) — 独自の曲線を定義します。cubic-bezier.comのようなツールでカスタム曲線を視覚的に構築・プレビューできます。
css
/* Custom spring-like easing with cubic-bezier */
.drawer {
  transform: translateX(-100%);
  transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.drawer.is-open {
  transform: translateX(0);
}

/* The cubic-bezier values above overshoot slightly (y > 1)
   which gives a satisfying spring effect on open */

@keyframes — トランジションでは足りない時

トランジションには明確な制限があります:2つの状態とトリガーが必要です。無限ループさせたい、インタラクションなしにページロード時に実行したい、2ステップ以上でアニメートしたい場合は@keyframesが必要です。MDNの@keyframesリファレンスが完全な構文をカバーしています。キーフレームを別に定義して、animationプロパティで要素に適用します。

css
/* Define the animation */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Attach it to an element */
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* Multi-step — more than two stops */
@keyframes pulse-ring {
  0%   { transform: scale(1);    opacity: 1; }
  50%  { transform: scale(1.15); opacity: 0.7; }
  100% { transform: scale(1);    opacity: 1; }
}

.badge-live {
  animation: pulse-ring 1.5s ease-in-out infinite;
}

animationショートハンド — 8つのプロパティすべて

animationショートハンドは8つのプロパティを1つの宣言にまとめます。毎回すべてが必要なわけではありませんが、各プロパティが何を制御するかを知っておくことで、何かが期待通りに動作しない時にドキュメントを掘り返す手間が省けます。

css
/*
  animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
  animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}

/* More commonly written as just the values you need: */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/* Breaking it out to individual properties for clarity: */
.confetti-piece {
  animation-name: float-down;
  animation-duration: 2s;
  animation-timing-function: ease-in;
  animation-delay: calc(var(--i) * 150ms); /* staggered via CSS custom property */
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: forwards;
  animation-play-state: running;
}

/* Pausing an animation via JavaScript: toggle a class */
.spinner.is-paused {
  animation-play-state: paused;
}

animation-fill-mode — スナップバック問題を解決する

これは誰もが一度は詰まるプロパティです。デフォルトでは、アニメーションが終わると要素はアニメーション前のスタイルに元通りに戻ります — アニメーションが起きなかったかのように。右からスライドインするトーストノーティフィケーションをアニメートすると、アニメーション終了と同時に画面外に飛び戻ります。animation-fill-mode: forwardsはアニメーション終了後も要素を最終キーフレームの状態に維持することでこれを解決します。

css
@keyframes slide-in-right {
  from {
    transform: translateX(110%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Without forwards: toast slides in, then SNAPS back off-screen */
.toast-bad {
  animation: slide-in-right 350ms ease-out;
}

/* With forwards: toast slides in and STAYS in position */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/*
  fill-mode values:
  - none      (default) — no styles applied before or after
  - forwards  — hold the final keyframe after the animation ends
  - backwards — apply the first keyframe during the delay period
  - both      — forwards + backwards combined
*/

実例 — スケルトンローダー、スピナー、トースト、パルスバッジ

常に使う4つのパターンを紹介します。それぞれが@keyframesとアニメーションプロパティの特定の組み合わせを実演しています。

css
/* --- 1. Skeleton loading shimmer --- */
@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
  border-radius: 4px;
}

.skeleton-title  { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text   { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }
css
/* --- 2. Spinning loader --- */
@keyframes spin {
  to { transform: rotate(360deg); }
}

.loader-spinner {
  display: inline-block;
  width: 32px;
  height: 32px;
  border: 3px solid rgba(79, 70, 229, 0.2);
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
css
/* --- 3. Slide-in notification toast --- */
@keyframes toast-enter {
  from {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes toast-exit {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
}

.toast {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  padding: 0.875rem 1.25rem;
  background: #1f2937;
  color: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  animation: toast-enter 350ms ease-out forwards;
}

.toast.is-dismissing {
  animation: toast-exit 300ms ease-in forwards;
}
css
/* --- 4. Pulsing notification badge --- */
@keyframes badge-pulse {
  0%, 100% {
    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
  }
  50% {
    box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
  }
}

.notification-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  border-radius: 999px;
  animation: badge-pulse 2s ease-in-out infinite;
}

パフォーマンス — GPU合成プロパティ vs レイアウトトリガープロパティ

アニメーションパフォーマンスにおいて、CSSプロパティはすべて同じではありません。ブラウザのレンダリングパイプラインにはここで重要な3つのステージがあります:レイアウト(要素のサイズと位置を計算)、ペイント(ピクセルを塗りつぶす)、コンポジット(GPUでレイヤーを結合)。レイアウトをトリガーするプロパティをアニメートすると、毎フレームごとにドキュメント全体のジオメトリが再計算されます — これは高コストで、カクつきの原因です。web.devのアニメーションパフォーマンスガイドがこれを詳しく解説しています。

  • アニメート安全(GPU合成): transform(translate、scale、rotate)とopacity。これらはGPUコンポジタースレッドで完全に実行されます — レイアウトもペイントもトリガーしません。
  • ペイントを引き起こす(可能なら避ける): colorbackground-colorborder-colorbox-shadow。これらはレイアウトをスキップしますが、毎フレームで再ペイントをトリガーします。短いトランジション(300ms未満)は通常問題ありません。
  • レイアウトを引き起こす(絶対にアニメートしない): widthheighttopleftmarginpadding。毎フレームで完全なレイアウト再計算がトリガーされます — 複雑なページでは確実にカクつきます。
実践的なルール:transformopacityだけでアニメートしましょう。何かを移動させたいならleft/topではなくtranslateを使います。何かをリサイズしたいならwidth/heightではなくscaleを使います。CSS Triggersは各プロパティがどのレンダリングステージをヒットするかを正確にリストした参考資料です。
css
/* Bad — animating width triggers layout on every frame */
.progress-bar-bad {
  transition: width 300ms ease;
}

/* Good — use scaleX transform instead */
.progress-bar {
  transform-origin: left center;
  transition: transform 300ms ease;
}

/* In JS, set: progressBar.style.transform = 'scaleX(0.75)' for 75% */

/* Bad — animating top/left (positions element with layout) */
.tooltip-bad {
  position: absolute;
  top: 0;
  transition: top 200ms ease;
}

/* Good — use translateY instead */
.tooltip {
  position: absolute;
  top: 0;
  transform: translateY(0);
  transition: transform 200ms ease;
}

will-change — 控えめに使う

will-changeは特定のプロパティがアニメートされようとしているというブラウザへのヒントで、事前に要素を独自のコンポジタレイヤーに昇格させます。これにより、ローエンドのハードウェアでアニメーションの最初に時々見られるカクつきの一瞬を排除できます。しかし現実のコストがあります:昇格された各レイヤーがGPUメモリを消費します。アプリのすべてのアニメート要素にwill-change: transformを設定すると、ほとんどのデバイスでパフォーマンスが低下します — 望んでいた結果の逆です。

css
/* Right way — add it just before animation starts, remove after */
.modal-overlay {
  /* Don't set will-change here by default */
}

/* Add via JavaScript only when user triggers modal open */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/*   overlay.style.willChange = 'auto'; */
/* }); */

/* Acceptable in CSS for elements that animate frequently
   (e.g., a persistent floating action button on scroll) */
.fab {
  will-change: transform; /* OK — this element genuinely animates on scroll */
  transition: transform 200ms ease;
}

/* Don't do this — wastes GPU memory with zero benefit */
.card {
  will-change: transform; /* BAD — card only animates on hover, not constantly */
}

prefers-reduced-motion — 無視できないアクセシビリティ

かなりの割合のユーザーが前庭障害やその他の状態を持っており、動きが不快感や吐き気を引き起こします。prefers-reduced-motionメディアクエリを使うと、システムレベルの「モーションを減らす」設定を尊重できます。WCAG 2.1ガイドライン2.3.3がこの要件をカバーしており、MDNのprefers-reduced-motionリファレンスがブラウザサポートを示しています(今や普遍的です)。パターンはシンプルです:標準のメディアクエリでアニメーションをラップし、モーション削減のメディアクエリ内でモーションなしのフォールバックを提供します。

css
/* Define animations normally for users who are fine with motion */
@keyframes slide-in-right {
  from { transform: translateX(110%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

.spinner {
  animation: spin 600ms linear infinite;
}

/* Override for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  .toast {
    /* No sliding — just appear */
    animation: none;
    opacity: 1;
    transform: none;
  }

  .spinner {
    /* Slow it way down or stop it entirely */
    animation-duration: 4s;
  }

  /* Nuclear option — disable ALL animations site-wide */
  /* Use this only if you haven't audited each animation individually */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

そのスニペット末尾の「核オプション」は、各アニメーションを監査していない既存のコードベースで一般的なパターンです。何もしないよりはましですが、各アニメーションを個別に監査すると、より多くの制御が得られます — 一部のアニメーションは状態を伝えます(プログレスバー、ローディングスピナー)ので、維持すべきですが、単に遅くするだけで良いです。

トランジション vs @keyframes — 決定ガイド

どちらを使うべきか迷ったら、構築しているアニメーションについて2つの質問をしてみましょう:

  • 状態変化によってトリガーされますか?(ホバー、フォーカス、クラストグル、チェックボックス)→ トランジションを使います。それがまさにトランジションが設計された目的です。
  • 無限にループしますか?animation-iteration-count: infiniteを使った@keyframesを使います。
  • 2ステップ以上ありますか?(開始→終了だけでなく、開始→中間→終了以上)→ @keyframesを使います。
  • ユーザーインタラクションなしにページロード時に実行する必要がありますか?@keyframesを使います。
  • 終了後に要素が最終状態にとどまる必要がありますか?animation-fill-mode: forwardsを使った@keyframesを使います。
人々が引っかかる組み合わせ:背景色をなめらかにフェードするホバーエフェクト(トランジション)と、同じ要素の「新着」バッジが継続的にパルスする(@keyframes)の両方が欲しい場合。同じ要素に両方を使えます — 異なるプロパティを制御しており、互いに干渉しません。

まとめ

トランジションは状態駆動のモーションを扱います。@keyframesはその他すべてを扱います。パフォーマンスのために常にtransformopacityでアニメートしましょう — widthheighttopleftは除外します。要素が最終状態を保持する必要があるときは常にanimation-fill-mode: forwardsを使います。何かをリリースする前にprefers-reduced-motionオーバーライドを追加してください — 実際のユーザーに大きく関わる10行の追加です。コンポジティングモデルとレイアウトをトリガーするものについての詳細は、web.devのレンダリングパフォーマンスドキュメントが最も実用的な資料です。CSSを書いたら、ショートハンドを読みやすく保つためにCSSフォーマッターに通すか、本番出荷前にホワイトスペースを除去するためにCSSミニファイアーに通してください。