TECH · CSSアニメーション

意味のある動きだけを付ける

サンプルとコード

01

ホバーとフォーカスの演出

単独で開く ↗

動かす対象を名指しし、位置は transform で動かします。ホバーだけでなくフォーカスにも同じ演出を当てているので、キーボード操作でも状態が伝わります。

HTML
<div class="row">
  <button class="btn" type="button">保存する</button>
  <a class="link" href="#">詳しい使い方を見る</a>
</div>

<article class="card">
  <h3>カードは持ち上げすぎない</h3>
  <p>2px ほどの移動と、遠くて薄い影。これだけで触れる対象だと伝わります。大きく動かすと一覧全体が落ち着かなくなります。</p>
</article>
CSS
/* transition: all は書かない。何が動くのか読めなくなるうえ、
   意図しないプロパティまで拾って重くなる。動かす対象を名指しする。 */
.btn {
  --lift: 0;
  display: inline-flex; align-items: center; gap: 0.4rem;
  padding: 0.65rem 1.3rem;
  border: 0; border-radius: 10px;
  background: var(--accent); color: #fff;
  font: inherit; font-weight: 700; line-height: 1.7;
  cursor: pointer;
  /* 動かすのは transform と box-shadow だけ。
     left/top ではなく transform を使うと、再描画ではなく合成で済む。 */
  transform: translateY(var(--lift));
  transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
  box-shadow: 0 2px 6px -2px rgba(37, 99, 235, 0.5);
}
.btn:hover { --lift: -2px; background: #1d4ed8; box-shadow: 0 12px 22px -10px rgba(37, 99, 235, 0.8); }
.btn:active { --lift: 0px; transition-duration: 0.06s; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* 下線を左から伸ばす。scaleX は幅の再計算を起こさない */
.link {
  position: relative; color: var(--ink); text-decoration: none;
  font-weight: 600; line-height: 1.7;
}
.link::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -2px;
  height: 2px; background: var(--accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.22s ease;
}
.link:hover::after, .link:focus-visible::after { transform: scaleX(1); }

/* カードは持ち上げるより、境界と影で「触れる」ことを示すほうが上品 */
.card {
  background: #fff; border: 1px solid var(--line);
  border-radius: 12px; padding: 1rem 1.1rem;
  transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.card:hover {
  border-color: #c7d2fe;
  box-shadow: 0 18px 30px -22px rgba(27, 32, 48, 0.55);
  transform: translateY(-2px);
}

/* 動きを減らす設定では、位置の変化だけ止めて色の変化は残す。
   「押せること」の手がかりまで消さないのがコツ。 */
@media (prefers-reduced-motion: reduce) {
  .btn, .card, .link::after { transition-duration: 0.01ms; }
  .btn:hover, .card:hover { transform: none; }
}
AIへの指示文
ボタン・リンク・カードのホバー演出を作ってください。
要件:
- transition: all は使わない。transform / box-shadow / background-color のように
  動かすプロパティを名指しする。理由をコメントで書く。
- 位置の変化は top や left ではなく transform: translateY で行う。
  再描画ではなく合成で済むため。
- リンクの下線は ::after を scaleX(0) から scaleX(1) へ。transform-origin: left。
- :hover と :focus-visible の両方に同じ演出を当てる。
- :active では持ち上げを戻し、transition-duration を短くして押した感じを出す。
- prefers-reduced-motion: reduce では位置の変化だけを止め、
  色の変化は残す。押せる手がかりまで消さないため。
02

登場アニメーション

単独で開く ↗

keyframes でフェードアップ、ポップ、フェードインの3種。時間差は nth-child で0.06秒ずつずらし、animation-fill-mode: backwards で遅延中の一瞬の表示を防いでいます。

HTML
<span class="badge">新着 4 件</span>

<div class="enter">
  <div class="row"><h3>注文が確定しました</h3><p>2 営業日以内に発送します。</p></div>
  <div class="row"><h3>在庫が補充されました</h3><p>入荷待ちだった 3 商品が購入できます。</p></div>
  <div class="row"><h3>レビューが届きました</h3><p>★4.6 の評価が付きました。</p></div>
  <div class="row"><h3>請求書を発行しました</h3><p>8 月分の明細をご確認ください。</p></div>
</div>

<p class="note">時間差は nth-child で 0.06 秒ずつずらしています。animation-fill-mode: backwards を付けているので、遅延の間も透明なまま待機します。</p>
CSS
@keyframes fade-up {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes pop {
  0%   { opacity: 0; transform: scale(0.94); }
  60%  { opacity: 1; transform: scale(1.02); }
  100% { transform: scale(1); }
}

/* backwards を付けると、遅延中も開始時の状態(透明)を保つ。
   これが無いと、遅延の間だけ要素が見えてしまう。 */
.enter > * {
  animation: fade-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 時間差は nth-child でずらす。0.06s 刻みが目に自然 */
.enter > *:nth-child(1) { animation-delay: 0.00s; }
.enter > *:nth-child(2) { animation-delay: 0.06s; }
.enter > *:nth-child(3) { animation-delay: 0.12s; }
.enter > *:nth-child(4) { animation-delay: 0.18s; }

.badge { animation: pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 0.24s backwards; }
.note  { animation: fade-in 0.6s ease 0.3s backwards; }

@media (prefers-reduced-motion: reduce) {
  .enter > *, .badge, .note { animation: none; }
}
AIへの指示文
要素が順番に現れる登場アニメーションを作ってください。
要件:
- @keyframes を3つ定義する。fade-up(下から12px + 透明から)、
  fade-in(透明から)、pop(0.94倍から1.02倍を経て等倍へ)。
- 一覧の各行には animation-delay を nth-child で 0.06s ずつずらして当てる。
- animation-fill-mode: backwards を必ず付ける。無いと遅延中に要素が見えてしまう。
  この理由をコメントで書く。
- 曲線は cubic-bezier(0.2, 0.8, 0.2, 1) を基本にし、
  pop だけ跳ねる cubic-bezier(0.34, 1.56, 0.64, 1) を使う。
- prefers-reduced-motion: reduce では animation を none にする。
03

スクロール連動

単独で開く ↗

animation-timeline を使い、IntersectionObserver を書かずにスクロール量へ動きを結び付けます。@supports で囲ってあるので、未対応環境では最初から見えている状態になります。

HTML
<div class="progress" aria-hidden="true"></div>

<div class="wrap">
  <p class="lead">下へスクロールしてください。各ブロックが視界に入るのに合わせて現れ、上部のバーが読み進めた量を示します。JavaScript は使っていません。</p>

  <div class="block reveal">
    <h3>交差の量が、そのまま進行度になる</h3>
    <p>animation-timeline: view() を書くと、要素とビューポートの交差量がアニメーションの再生位置に対応します。IntersectionObserver で閾値を管理する必要がありません。</p>
  </div>

  <div class="block reveal">
    <h3>範囲は animation-range で決める</h3>
    <p>cover 12% から cover 48% までを使う、という指定です。全域を使うと画面中央でようやく完成するので、読み物では前半で終わらせるほうが自然に感じます。</p>
  </div>

  <div class="block reveal">
    <h3>未対応環境では最初から見える</h3>
    <p>@supports で囲っているので、対応していないブラウザではアニメーションが当たらず、要素は通常どおり表示されます。内容が読めなくなることはありません。</p>
  </div>

  <div class="block reveal">
    <h3>動きを減らす設定を尊重する</h3>
    <p>prefers-reduced-motion: reduce のときはアニメーションを外し、進捗バーも止めます。動きそのものが体調に影響する人がいるため、これは装飾ではなく必須の配慮です。</p>
  </div>

  <div class="spacer"></div>
</div>
CSS
/* Scroll-driven animations。IntersectionObserver を書かずに、
   「要素がビューポートを通過する量」をアニメーションの進行度に直結できる。 */
@keyframes reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

/* 読み上げ順に影響しないよう、対応ブラウザだけに当てる。
   未対応環境では最初から見えている状態になり、内容は読める。 */
@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal linear both;
    /* view() = この要素とビューポートの交差を時間軸にする */
    animation-timeline: view();
    /* 下端に入り始めてから、上に 40% 進むまでで完了させる。
       cover 20% 〜 cover 55% あたりが読み物では自然。 */
    animation-range: cover 12% cover 48%;
  }
}

/* 上部の進捗バー。scroll() は「スクロール可能な祖先」を時間軸にする */
@supports (animation-timeline: scroll()) {
  .progress {
    position: fixed; inset: 0 0 auto 0; height: 3px; z-index: 10;
    background: var(--accent); transform-origin: left;
    animation: grow linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; opacity: 1; transform: none; }
  .progress { animation: none; transform: scaleX(0); }
}
AIへの指示文
スクロールに連動するアニメーションを、JavaScript なしで作ってください。
要件:
- @supports (animation-timeline: view()) で囲って、対応環境だけに当てる。
  未対応環境では要素が最初から見える状態にする。
- 各ブロックに animation-timeline: view() を当て、
  animation-range: cover 12% cover 48% で前半のうちに完了させる。
  全域を使うと画面中央でようやく完成して遅く感じる、と理由を書く。
- ページ上部に固定の進捗バーを置き、animation-timeline: scroll(root block) と
  scaleX(0) → scaleX(1) の keyframes で読み進めた量を示す。
- prefers-reduced-motion: reduce では両方とも止める。
04

継ぎ目のない無限ループ

単独で開く ↗

同じ並びを2回描き、全体の50%だけ動かして先頭へ戻すことで継ぎ目を消します。両端をマスクでぼかし、ホバーとフォーカスで停止します。

HTML
<div class="marquee">
  <div class="marquee-track" aria-hidden="true">
    <span><b>受注管理</b> 注文の一覧と絞り込み</span>
    <span><b>在庫連携</b> 1時間ごとに同期</span>
    <span><b>請求書</b> 月末締めで自動生成</span>
    <span><b>権限管理</b> 編集者と閲覧者の2段階</span>
    <span><b>監査ログ</b> 90日間保持</span>
    <!-- ここから同じ並びをもう1度。translateX(-50%) で先頭に戻る -->
    <span><b>受注管理</b> 注文の一覧と絞り込み</span>
    <span><b>在庫連携</b> 1時間ごとに同期</span>
    <span><b>請求書</b> 月末締めで自動生成</span>
    <span><b>権限管理</b> 編集者と閲覧者の2段階</span>
    <span><b>監査ログ</b> 90日間保持</span>
  </div>
</div>
CSS
/* 継ぎ目を消す仕組み: 同じ並びを2回描き、
   全体の 50%(=1周分)だけ動かして先頭へ戻す。 */
.marquee {
  overflow: hidden;
  border-block: 1px solid var(--line);
  background: #fff;
  padding: 0.7rem 0;
  /* 両端をぼかすと、切れているのではなく続いて見える */
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee-track {
  display: flex;
  gap: 2rem;
  width: max-content;
  animation: slide 22s linear infinite;
}
@keyframes slide {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* 読み進めたい人のために、ホバーとフォーカスで止める */
.marquee:hover .marquee-track,
.marquee:focus-within .marquee-track { animation-play-state: paused; }

/* 無限アニメーションは電池とCPUを常に使う。
   動きを減らす設定では完全に止め、先頭だけ見せる。 */
@media (prefers-reduced-motion: reduce) {
  .marquee-track { animation: none; }
  .marquee { mask-image: none; }
}
AIへの指示文
継ぎ目なく流れ続けるマーキーを作ってください。
要件:
- 中身は同じ並びを2回書く。translateX(0) から translateX(-50%) へ動かすと
  1周分ちょうどで先頭に戻り、継ぎ目が見えなくなる。仕組みをコメントで書く。
- トラックは display: flex と width: max-content。
- 両端は mask-image: linear-gradient でぼかし、切れているのではなく
  続いているように見せる。
- :hover と :focus-within で animation-play-state: paused にして止める。
- 流れる帯は aria-hidden="true" にする。同じ内容が2回読み上げられるため。
- prefers-reduced-motion: reduce では animation を止め、マスクも外す。
  無限アニメーションは電池とCPUを常に消費する点をコメントで書く。

この技術について

動きは装飾ではなく、状態の変化を説明する手段です。押せたのか、開いたのか、読み込み中なのか。これらは一瞬で切り替わると認識が追いつかず、0.2秒ほどの遷移を挟むだけで理解できるようになります。逆に、何も説明していない動きは注意を奪うだけで害になります。

だから書き始める前に一度だけ問うといい。この動きは何を伝えるのか。答えが出れば、時間の値も曲線も自然に決まります。

名指しすることと、合成で済ませること

実装で守るべき原則は2つだけです。

ひとつは transition: all を書かないこと。何が動くのかコードから読めなくなるうえ、あとから marginwidth を変える指定を足したとき、意図せずそこにも遷移が掛かります。動かす対象は必ず名指しします。

もうひとつは、位置を topleft ではなく transform で動かすこと。前者はレイアウトの再計算と再描画を起こしますが、transformopacity は合成だけで済みます。要素が数十個ある一覧をスクロールしながら動かすと、この差は体感としてはっきり出ます。

width や left は毎フレーム描き直すが、transform と opacity は合成だけで済む レイアウトからやり直す width / height / top / left 位置の計算 → 描き直し → 合成 毎フレーム全部やり直す 合成だけで済ませる transform / opacity できあがった絵をずらすだけ レイアウトに触らない 「動かす」と言っても、位置を変えるのではなく、描き終えた絵の見せ方を変えている
同じ見た目でも裏でやり直している量が違います。移動は left ではなく translate、表示切り替えは display ではなく opacity に寄せるのが基本になります。

遅延中に見えてしまう、という定番の失敗

一覧を時間差で登場させるとき、animation-delay だけを書くと遅延している間、要素が最終状態のまま見えています。0.18秒後に現れるはずのものが、最初から表示されていて、そこから急にアニメーションが始まる。

animation-fill-mode: backwards を付けると、遅延中も開始時の状態(透明)を保ちます。省略形なら animation: fade-up 0.5s ease backwards の末尾に足すだけです。地味ですが、これが無いと登場演出は成立しません。

delay を付けただけだと、待っている間そのまま見えてしまう delay だけ 最初から見えている 0.4秒後に、あらためて現れる 一度見えたものがもう一度出る違和感 開始状態を書く 最初は見えない 0.4秒後に、初めて現れる animation-fill-mode: both 開始前は最初のキーフレーム、終了後は最後のキーフレームの状態を保つ
順番に現れる演出で必ず起きます。待っている間の見た目を指定していないことが原因なので、遅延の値をいくら調整しても直りません。

監視を書かずにスクロールへ結び付ける

スクロールに応じて要素を現す実装は、長らく IntersectionObserver の仕事でした。いまは animation-timeline: view() を書くだけで、要素とビューポートの交差量がそのままアニメーションの再生位置になります。JavaScriptは1行も要りません。

範囲の指定にはコツがあります。全域を使うと画面のかなり中央まで来てようやく完成するので、読み物では遅く感じます。cover 12% から cover 48% のように前半で終わらせると自然です。対応していないブラウザのために @supports で囲い、未対応環境では最初から見えている状態にしておくこと。演出を前提にした実装にしないのが要点です。

animation-timeline: view() はスクロール量を再生位置として使う JavaScript で監視する scroll イベント → 位置を計算 → style を書き換え メインスレッドで毎回動く CSS で結び付ける animation-timeline: view() 時間の代わりにスクロール量で 進み具合が決まる 要素が視界に入り始めた瞬間が 0%、抜けきる瞬間が 100% 未対応のブラウザでは指定ごと無視される。だから「動かなくても成立する」形にしておく
スクロール量そのものが再生位置になるので、止めれば止まり、戻せば巻き戻ります。監視も間引きも要らず、指定は1行です。

止められる動きにしておく

無限に流れるマーキーは、目を引く一方で常にCPUと電池を使い続けます。最低限、ホバーとフォーカスで止まるようにしてください。読みたい人が読めなくなるのは本末転倒です。

そして prefers-reduced-motion: reduce の尊重。ここは好みの問題ではありません。画面内の予期しない動きは、前庭障害のある人にとって実際に体調不良を引き起こします。ただし全部を止めるのも行き過ぎで、位置の変化は止め、色の変化は残す。押せるという手がかりまで消さないのが実務的な線引きです。

動きを減らす設定では、動きだけを消して状態の変化は残す 全部止める animation: none 開いたか閉じたか分からなくなる 一瞬で終わらせる duration: 0.01ms 状態の変化は伝わる @media (prefers-reduced-motion: reduce) 動きで気分が悪くなる人がいる。装飾のためだけの動きは、この設定で確実に消す ただし「状態が変わったこと」まで消してはいけない。消すのは動きだけ
設定を尊重することと、情報まで削ることは別です。動きを消しても、開いた・閉じた・切り替わったという事実は残るようにしてください。

使いどころとつまずきどころ

向いている場面

  • ボタンやカードの押せる感じを伝える
  • 要素の登場・退場をなめらかに見せる
  • スクロールに連動した読み物の演出
  • ロゴやお知らせの無限ループ表示

つまずきやすい点

  • prefers-reduced-motion を無視すると体調不良の原因になる
  • left や top を動かすと再描画が重い。transform を使う
  • transition: all は意図しない変化まで拾う
  • 無限アニメーションは電池とCPUを常に消費する

よくある質問

transition: all を使ってはいけないのはなぜですか。

何が動くのかコードから読めなくなるうえ、意図しないプロパティまで拾うためです。あとから margin や width が変わる指定を足したとき、意図せずそこにも遷移が掛かって挙動が変わります。動かす対象は必ず名指ししてください。

位置を動かすとき top や left ではなく transform を使うのはなぜですか。

top や left を変えるとレイアウトの再計算と再描画が起きますが、transform は合成だけで済みます。要素数が多い一覧でスクロールしながら動かすと、この差が体感速度としてはっきり出ます。opacity も同じく合成だけで済む安全なプロパティです。

prefers-reduced-motion にはどこまで対応すべきですか。

位置や大きさが変わる動きは止め、色の変化は残すのが実務的な線です。すべて止めると「押せる」「選ばれている」といった手がかりまで消えてしまいます。前庭障害のある人にとって画面内の予期しない移動は実際に体調不良の原因になるので、移動だけは確実に止めてください。

スクロール連動は対応ブラウザが限られませんか。

限られます。だから @supports で囲い、未対応環境では要素が最初から見えている状態にしてください。この形にしておけば、対応が広がった環境では自然に演出が乗り、そうでない環境でも内容は普通に読めます。演出を前提にした実装にしないことが要点です。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術