
CSS Scroll-driven Animations 実践ガイド ― JSなしでスクロール連動アニメを作る
スクロール位置に連動するアニメーションを、JavaScriptゼロ、CSSだけで実装する方法を基礎から応用まで解説します。プログレスバー、フェードイン、パララックスまで、実動デモ付きで一気に身につけましょう。
スクロールで動く演出、まだJSで書いてますか?
「スクロールしたら要素がフェードインする」「ページの読了量をプログレスバーで可視化する」——こういった演出、今まではJavaScriptの scroll イベントや IntersectionObserver を使って書くのが定石でした。でも正直なところ、スクロールイベントを自分で管理するのはなかなかの重労働です。スロットリングの調整、rAFとの連携、スクロール量の正規化……やりたいことはシンプルなのに、コード量がどんどん膨れ上がるあの感じ、覚えがある方も多いのではないでしょうか。
CSS Scroll-driven Animations は、そんな悩みをまるごと解決してくれる仕組みです。ひとことで言えば、「スクロール位置をアニメーションのタイムラインとして使える」CSS機能。通常のCSSアニメーションは時間ベースで 0%→100% と進みますが、それを「スクロール量ベース」や「要素の表示位置ベース」に切り替えるだけ。@keyframes はそのまま使えるので、すでにCSSアニメーションを書いたことがある方なら、追加で覚えるプロパティは数個だけです。
しかもパフォーマンスにも優れています。JavaScriptのスクロールイベントはメインスレッドで動くため、処理が重くなるとカクつき(jank)の原因になります。一方、CSSベースのスクロール連動アニメーションはブラウザのコンポジタースレッドで処理されるため、60fps以上を安定して出せます。実務でリッチな演出を入れたいときに、「動きは綺麗だけどスクロールがガクガクする」という本末転倒を避けられるのは大きなメリットです。
scroll() でページ全体の進行を可視化する
まずは一番シンプルなパターンから。animation-timeline: scroll() を使うと、「スクロールコンテナのスクロール量」にアニメーションを連動させられます。スクロール量 0% のとき @keyframes の from、スクロール量 100% のとき to にマッピングされる——直感的でわかりやすい仕組みです。
最小構成:読了プログレスバー
ブログやドキュメントページでよく見る「ページ上部にじわじわ伸びるプログレスバー」を、CSSだけで作ってみます。コアとなるCSSはたったこれだけです。
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, #7c3aed, #c4b5fd);
transform-origin: left;
animation: grow-bar linear;
animation-timeline: scroll();
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
ポイントは3つです。まず animation-timeline: scroll() で「最も近い祖先スクロールコンテナ(通常はページ全体)のスクロール量」をタイムラインに指定しています。次に、animation ショートハンドの中で animation-duration に相当する値を省略していますが、scroll-driven animation では時間ではなくスクロール量で進行するため、duration は本質的に無意味です。ただし Firefox の互換性を考慮して 1ms を明示するのが現場のベストプラクティスです。最後に、transform: scaleX() を使うことで、GPU合成で処理が完結し、パフォーマンスが最大化されます。width をアニメーションさせるより圧倒的に滑らかです。
scroll() の引数
scroll() 関数はオプションで2つの引数を取ります。1つ目はスクローラーの指定で nearest(最も近い祖先、デフォルト)、root(ルート要素)、self(自分自身)のいずれか。2つ目はスクロール軸で block(デフォルト)、inline、x、y から選べます。
/* ページ全体の縦スクロール(デフォルト) */
animation-timeline: scroll();
/* ルート要素のスクロール */
animation-timeline: scroll(root);
/* 自分自身の横スクロール */
animation-timeline: scroll(self inline);
/* 最も近い祖先のインライン方向 */
animation-timeline: scroll(nearest inline);
下のボックス内をスクロールすると、上部のバーが伸びます。
これはスクロール連動プログレスバーのデモです。ここからどんどん下にスクロールしてみてください。
スクロールが進むにつれて、上部のバーが左から右へじわじわ伸びていきます。CSS だけで動いている点に注目してください。
animation-timeline: scroll() は、このスクロールコンテナのスクロール進行率を 0%〜100% にマッピングし、@keyframes の from〜to に対応させています。
transform: scaleX() を使っているので GPU 合成で処理が完結し、スクロールがカクつくことなくスムーズに動きます。
JavaScript は一行も書いていません。スクロールイベントのリスナーも、IntersectionObserver も不要です。
ぜひ上にも戻してみてください。スクロール位置に正確に連動して、バーが縮んでいくのがわかるはずです。
この仕組みの素晴らしいところは、通常の CSS アニメーション(@keyframes)をそのまま再利用できること。新しく覚えるのは animation-timeline プロパティだけです。
ここが最下部です。バーが右端まで伸びていれば成功です!
view() で「要素が見えたら動く」を作る
scroll() が「スクロールコンテナ全体の進行量」に連動するのに対し、view() は「ある要素がスクロールポート(表示領域)に入ってから出ていくまで」をタイムラインにします。つまり「要素の可視状態」そのものがアニメーションのトリガーです。
これまで IntersectionObserver + クラス付与で実装していた「スクロールするとカードがフェードインする」系の演出が、CSSの2行で書けるようになります。
.card {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
animation-range を理解する
animation-range はここが核心です。view() タイムラインでは、要素の可視状態が段階的に変わるため、「いつからいつまでアニメーションを進めるか」を細かく指定できます。
使えるキーワードは entry(要素がスクロールポートに入り始める~完全に入るまで)、exit(要素が出始める~完全に出るまで)、contain(要素が完全にスクロールポートに収まっている間)、cover(一部でも見えている全区間、デフォルト)の4つです。それぞれにパーセント値を組み合わせることで、「入り始めの 0%〜100% の間だけアニメーションする」のような指定が可能です。
/* 要素が入り始め〜完全に見えるまでの間で完了 */
animation-range: entry 0% entry 100%;
/* 入り始めの前半30%だけで完了(素早くフェードイン) */
animation-range: entry 0% entry 30%;
/* 入る~出るまで全区間(デフォルト = cover 0% cover 100%) */
animation-range: cover 0% cover 100%;
/* 要素が出始め〜完全に出るまでフェードアウト */
animation-range: exit 0% exit 100%;
実務で一番よく使うのは entry 0% entry 100%。要素が画面に入り始めた瞬間からアニメーションがスタートし、完全に見えた時点で完了する設定です。スクロールに連動して自然に現れるので、「パッと出る」のではなく「じわっと見えてくる」体験になります。
下のボックス内をスクロールすると、カードがフェードインします。
実務で使えるパターン集
基礎がわかったところで、現場でそのまま使えるパターンを見ていきます。NG例とOK例の比較も交えながら、「なぜそう書くのか」の判断基準を身につけましょう。
パターン1:パララックス風の背景ズレ
背景要素をスクロール速度の違いで前景とずらすパララックス効果も、scroll() で実現できます。ポイントは、前景と背景で translateY の移動量を変えること。JSで scrollY * 0.5 のような計算をしていたアレが、CSSだけで書けます。
// メインスレッドをブロックしやすいパターン
window.addEventListener('scroll', () => {
const y = window.scrollY;
bg.style.transform = `translateY(${y * 0.3}px)`;
});
.parallax-bg {
animation: parallax-shift linear;
animation-timeline: scroll();
}
@keyframes parallax-shift {
from { transform: translateY(0); }
to { transform: translateY(-120px); }
}
JS版はスクロールのたびにメインスレッドでスタイルの再計算が走ります。CSS版はコンポジタースレッドで処理が完結するため、どれだけ高速にスクロールしてもカクつきません。実装もシンプルになるので、まさに一石二鳥です。
スクロールすると、背景の円と前景のテキストの動く速度が異なります。
前景テキストは通常速度でスクロールされます。
背景の紫の円は、CSSアニメーションでゆっくり動きます。
この速度差がパララックス効果を生みます。
JavaScriptは一切使っていません。
transform: translateY() を使っているので GPU 処理です。
animation-timeline: scroll() ひとつで実現しています。
ぜひ上にも戻してみてください。
パターン2:カードグリッドの時差フェードイン
複数の要素を順番にフェードインさせたい場面、よくありますよね。view() を各カードに設定するだけで、スクロール位置に応じて自然にずれてフェードインします。なぜなら、それぞれのカードの「スクロールポートへの入り方」が物理的にずれているからです。各カードの配置位置が異なるので、animation-delay すら不要です。
.grid-item {
animation: stagger-in linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 100%;
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateY(30px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
大事なのは、同じ行にある要素は同時に入ってくるため同時にアニメーションする、という点。もし同じ行でもずらしたい場合は、各カードに異なる animation-range のオフセットを指定する手もあります。とはいえ、多くの場合は上のコードだけで十分自然に見えます。
下のボックス内をスクロールすると、カードが順にフェードインします。
パターン3:名前付きタイムラインで別要素を制御する
ここまでの scroll() と view() は「匿名タイムライン」と呼ばれる書き方です。でも現場では「スクロールコンテナAのスクロール量に連動して、子孫要素Bをアニメーションさせたい」というケースもあります。そんなときは「名前付きタイムライン」を使います。
/* スクローラーに名前をつける */
.scroller {
scroll-timeline-name: --main-scroll;
overflow-y: scroll;
}
/* 子孫要素にそのタイムラインを適用 */
.animated-child {
animation: rotate-in linear;
animation-timeline: --main-scroll;
}
scroll-timeline-name には -- で始まるカスタム識別子(CSS変数と同じ命名規則)を使います。同様に view-timeline-name を使えば、「特定の要素の可視状態」を名前付きで別の要素に渡すことも可能です。レイアウトが複雑になってきたとき、匿名タイムラインでは「最も近い祖先スクローラー」が意図と違うスクローラーになってしまう場合があるので、その回避策としても名前付きタイムラインは重宝します。
アクセシビリティは忘れずに ― prefers-reduced-motion
スクロール連動アニメーションは視覚効果としては魅力的ですが、前庭障害(vestibular disorder)のあるユーザーにとっては、画面上の過剰な動きがめまいや吐き気を引き起こすことがあります。これはリッチな演出を入れるときに常に頭に置いておくべきことです。
対応はシンプル。prefers-reduced-motion: reduce メディアクエリで、アニメーションを無効化するだけです。
@media (prefers-reduced-motion: reduce) {
.card,
.progress-bar,
.parallax-bg {
animation-timeline: none;
}
}
animation-timeline: none を指定すると、タイムラインそのものが解除されます。animation: none で全プロパティをリセットするよりも、意図が明確で副作用が少ないのがこの書き方の利点です。
ひとつ注意点として、animation ショートハンドは animation-timeline を auto にリセットしてしまうため、セレクタの詳細度に気をつけてください。安全策として、reduced-motion 用の宣言には元のセレクタと同じかそれ以上の詳細度を持たせておくのがおすすめです。
スクロールするとボックスが回転しながらフェードインします。
非対応ブラウザ向けのフォールバック
Firefox など未対応ブラウザを考慮するなら、@supports を使って分岐するのが安全です。scroll-driven animation 非対応のブラウザでは、アニメーションなしの状態がデフォルトで表示されるようにしておけば、体験としては「動きがないだけ」で、コンテンツへのアクセスには支障がありません。
/* アニメーションなしの状態をデフォルトにする */
.card {
opacity: 1;
transform: none;
}
/* 対応ブラウザでだけアニメーションを有効にする */
@supports (animation-timeline: view()) {
.card {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
この「対応ブラウザでだけアニメーションを追加する」というアプローチが、プログレッシブ・エンハンスメントの基本的な考え方です。非対応ブラウザでも壊れない、対応ブラウザではリッチになる。この安心感があるからこそ、新しいCSSプロパティを現場に導入しやすくなります。
まとめ
- CSS Scroll-driven Animations は、スクロール量や要素の可視状態をアニメーションのタイムラインとして使える CSS 機能で、JavaScript なしでスクロール連動演出を実装できる
- scroll() 関数は「スクロールコンテナの進行量 0%〜100%」を @keyframes にマッピングし、プログレスバーやパララックスに最適
- view() 関数は「要素がスクロールポートに入ってから出るまで」をタイムラインにし、フェードインや表示連動アニメーションに使う
- animation-range で entry / exit / contain / cover を指定し、アニメーションが再生される区間を細かく制御できる
- animation ショートハンドは animation-timeline を auto にリセットするため、animation-timeline は必ずショートハンドの後に記述する
- コンポジタースレッドで処理されるため、JS の scroll イベント方式よりパフォーマンスが高く、カクつきが起きにくい
- prefers-reduced-motion: reduce で激しいアニメーションを無効化し、@supports で非対応ブラウザ向けのフォールバックを書くのが現場のベストプラクティス