CSS の scroll-driven animations でスクロール連動アニメーションをつくる

スクロールに連動して要素がフェードインしたり、プログレスバーが伸びたりする演出は、これまで JavaScript の Intersection Observer や scroll イベントで実装するのが一般的でした。CSS の scroll-driven animations を使えば、こうしたスクロール連動アニメーションを CSS だけで実現できます。JavaScript が不要になるぶん、コード量が減り、メインスレッドをブロックしないためパフォーマンスも向上します。

scroll-driven animations の 2 つのタイムライン

従来の CSS アニメーションは時間の経過によって進行しますが、scroll-driven animations ではスクロール量がアニメーションの進行を制御します。タイムラインには 2 種類あり、用途に応じて使い分けます。

scroll()(スクロール進行タイムライン)

スクロールコンテナ全体のスクロール位置に連動する。ページ最上部で 0%、最下部で 100% になる。読了プログレスバーなどに向いている。

view()(ビュー進行タイムライン)

特定の要素がビューポートに出入りするタイミングに連動する。要素が見え始めると 0%、完全に見えると 100% になる。フェードインや横からのスライドインに向いている。

scroll() によるプログレスバー

ページ全体のスクロール量に応じて幅が伸びるプログレスバーを作ってみましょう。まず通常の @keyframes を定義し、それを scroll() タイムラインで駆動させます。

@keyframes progress {
  from {
    width: 0%;
  }
  to {
    width: 100%;
  }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: #6366f1;
  animation: progress linear;
  animation-timeline: scroll();
}

ポイントは animation-timeline: scroll() の指定です。これにより、アニメーションの進行が時間ではなくスクロール量に紐づきます。animation-duration を指定する必要はなく、スクロール位置が 0% から 100% に変化するのに合わせてキーフレームが自動的に進行します。

HTML
CSS
JavaScript
<div class="scroll-demo">
  <div class="prog-bar"></div>
  <div class="scroll-content">
    <p>スクロールしてみてください</p>
    <p style="margin-top:200px">まだまだ下があります</p>
    <p style="margin-top:200px">もう少しです</p>
    <p style="margin-top:200px">最下部に到達しました</p>
  </div>
</div>
.scroll-demo {
  position: relative;
  height: 180px;
  overflow-y: auto;
  border-radius: 8px;
  border: 1px solid #e5e7eb;
}
.prog-bar {
  position: sticky;
  top: 0;
  left: 0;
  height: 4px;
  background: #6366f1;
  animation: prog linear;
  animation-timeline: scroll();
}
@keyframes prog {
  from { width: 0%; }
  to { width: 100%; }
}
.scroll-content {
  padding: 16px;
}
.scroll-content p {
  color: #374151;
  font-size: 14px;
}

スクロールするとバーが伸び、最下部まで到達すると 100% になります。JavaScript は一切使っていません。

scroll() の引数でスクロール対象を指定する

scroll() にはスクロール対象と方向を引数として渡せます。

animation-timeline: scroll(nearest block);

第 1 引数はスクロールコンテナの指定で、nearest(最も近い祖先)、root(ドキュメントルート)、self(要素自身)のいずれかを選べます。第 2 引数は方向で、block(ブロック方向、通常は縦)または inline(インライン方向、通常は横)を指定します。デフォルトは scroll(nearest block) なので、多くの場面では引数なしの scroll() で十分です。

view() によるフェードイン

スクロールして要素がビューポートに入ったときにフェードインさせるには、view() タイムラインを使います。

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

.card {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

animation-timeline: view() を指定すると、その要素自身がスクロールコンテナのビューポートに出入りするタイミングにアニメーションが連動します。ここで重要になるのが animation-range プロパティです。

animation-range でアニメーション区間を制御する

view() タイムラインには、要素の出入りに応じた複数のフェーズがあります。animation-range を使って、アニメーションをどのフェーズで実行するかを細かく指定できます。

entry

要素がビューポートに入り始めてから完全に入りきるまでの区間です。entry 0% が見え始め、entry 100% が完全に見えた瞬間にあたります。フェードインのトリガーとして最もよく使います。

exit

要素がビューポートから出始めてから完全に消えるまでの区間です。exit 0% が出始め、exit 100% が完全に見えなくなった瞬間です。フェードアウトに使います。

contain

要素が完全にビューポート内に収まっている区間です。要素が完全に見えている間だけスタイルを適用したい場合に使います。

たとえば、entry の後半 50% だけでフェードインさせたい場合は次のように書きます。

.card {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 50% entry 100%;
}

こうすると、要素が半分ほど見えた時点からアニメーションが始まり、完全に見えた時点で完了します。entry の範囲を狭くするほどアニメーションが速く、広くするほどゆっくりになります。

HTML
CSS
JavaScript
<div class="view-demo">
  <p class="spacer-text">下にスクロールしてください</p>
  <div class="fade-card" style="background:#6366f1">Card A</div>
  <div class="fade-card" style="background:#8b5cf6">Card B</div>
  <div class="fade-card" style="background:#a855f7">Card C</div>
  <div class="fade-card" style="background:#d946ef">Card D</div>
</div>
.view-demo {
  height: 180px;
  overflow-y: auto;
  border-radius: 8px;
  border: 1px solid #e5e7eb;
  padding: 16px;
}
.spacer-text {
  font-size: 13px;
  color: #666;
  margin: 0 0 200px 0;
}
.fade-card {
  height: 80px;
  margin-bottom: 120px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 16px;
  font-weight: bold;
  animation: fadeUp linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
@keyframes fadeUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

各カードがビューポートに入るにつれて、下からふわっと浮き上がるように表示されます。

timeline-scope で離れた要素を連動させる

デフォルトでは、scroll() や view() のタイムラインはその要素の祖先スクロールコンテナに紐づきます。しかし、タイムラインを生成する要素とアニメーションする要素が兄弟関係にある場合など、直接の祖先・子孫関係にないケースもあります。そのようなとき、共通の祖先に timeline-scope を指定することでタイムラインの参照範囲を広げられます。

.wrapper {
  timeline-scope: --my-scroll;
}

.scroll-area {
  scroll-timeline-name: --my-scroll;
  overflow-y: auto;
}

.indicator {
  animation: grow linear;
  animation-timeline: --my-scroll;
}

.scroll-area で定義した名前付きタイムライン --my-scroll を、兄弟要素の .indicator から参照しています。通常なら参照できませんが、共通の親 .wrapper に timeline-scope: --my-scroll を指定することで、タイムラインのスコープが wrapper 全体に広がり、兄弟要素からもアクセス可能になります。

ブラウザ対応状況と注意点

scroll-driven animations は 2024 年時点で Chrome 115 以降と Edge 115 以降でサポートされています。Firefox はフラグ付きで実験的にサポートしており、Safari は未対応の状態が続いています。

Safari が未対応であるため、プロダクション環境で使う場合は @supports によるフィーチャーディテクションを行い、非対応ブラウザでは静的な表示にフォールバックさせるのが安全です。

@supports (animation-timeline: scroll()) { … } で対応ブラウザのみにスタイルを適用する手法。

@supports (animation-timeline: scroll()) {
  .card {
    animation: fade-in linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

@supports で囲んでおけば、非対応ブラウザでは .card に対するアニメーション指定自体が無視されるため、要素は最初から通常の状態で表示されます。アニメーションがなくてもコンテンツは閲覧できるというプログレッシブ・エンハンスメントの考え方に沿った実装になります。

scroll-driven animations は、スクロール連動の演出を CSS に閉じ込められるという大きなメリットがあります。Safari の対応が進めば、Intersection Observer を使った JavaScript 実装の多くを置き換えられるようになるでしょう。今のうちに書き方を押さえておくと、対応ブラウザが広がったときにスムーズに導入できます。