CSS Scroll-Driven Animationsでスクロール連動アニメーション〜JSなしでここまでできる

2026.02.06 09:00
2026.03.16 13:32
CSS Scroll-Driven Animationsでスクロール連動アニメーション〜JSなしでここまでできる

スクロールに連動したアニメーションといえば、これまではJavaScriptが必須でした。
IntersectionObserverやscrollイベントを使ってゴリゴリ書くか、GSAPのScrollTriggerみたいなライブラリに頼るか。

でも今、CSSだけでスクロール連動アニメーションが作れる時代が来ています。
それが CSS Scroll-Driven Animations です。

自分も最近のプロジェクトで使ってみたんですが、これがかなり快適で。
JSを一切書かずにプログレスバーやフェードイン、パララックス風の演出までできてしまいます。
今回はその基本から実例まで、まとめて紹介してみます。

サンプルページを見る(プログレスバー・フェードイン・パララックスのデモ)

Scroll-Driven Animationsとは

Scroll-Driven Animationsは、CSSアニメーションの進行を「時間」ではなく「スクロール位置」に連動させる仕様です。

通常のCSSアニメーションは animation-duration: 2s のように時間ベースで進行しますよね。
Scroll-Driven Animationsでは、代わりに animation-timeline プロパティを使って、スクロール量に応じてアニメーションが進行するようになります。

つまり、ページを50%スクロールしたらアニメーションも50%進む、という仕組みです。
スクロールを止めればアニメーションも止まるし、上に戻せば巻き戻ります。

ポイントは以下の3つ。

  • JSが不要 — CSSだけで完結する
  • メインスレッドをブロックしない — ブラウザのコンポジタースレッドで処理されるため、パフォーマンスが良い
  • 既存の@keyframesをそのまま使える — 新しいアニメーション構文を覚える必要がない

2つのタイムライン: scroll-timeline と view-timeline

Scroll-Driven Animationsには大きく分けて2種類のタイムラインがあります。

Scroll Progress Timeline(scroll-timeline)

スクロールコンテナ全体のスクロール量に連動するタイムラインです。
ページの一番上が0%、一番下が100%になります。

使い方は簡単で、animation-timeline: scroll() と書くだけ。
「ページ全体のスクロール進捗に合わせてアニメーションさせたい」ときに使います。
プログレスバーなんかが典型例ですね。

View Progress Timeline(view-timeline)

特定の要素がビューポート(スクロール領域)に出入りするタイミングに連動するタイムラインです。
要素がビューポートに入り始めたら0%、完全に出て行ったら100%になります。

こちらは animation-timeline: view() と書きます。
「要素が画面に入ってきたらフェードインさせたい」みたいな用途にぴったりです。

ざっくり比較

scroll() → スクロールコンテナの全体量が基準。ページ全体に対する進捗を見たいとき。
view() → 個別の要素の表示状態が基準。要素が画面に見えるかどうかで制御したいとき。

animation-timeline: scroll() の基本

まずは scroll() の基本構文から見ていきます。

.element {
  animation: myAnimation linear both;
  animation-timeline: scroll();
}

たったこれだけです。
既存の @keyframes をそのまま使い、animation-timeline: scroll() を追加するだけでスクロール連動になります。

scroll() 関数には引数を渡すこともできます。

/* デフォルト: 最も近い祖先スクロールコンテナ、block軸 */
animation-timeline: scroll();

/* ルート要素(html)のスクロールに連動 */
animation-timeline: scroll(root);

/* インライン方向(横スクロール)に連動 */
animation-timeline: scroll(nearest inline);

/* 特定の軸を指定 */
animation-timeline: scroll(root block);  /* 縦方向 */
animation-timeline: scroll(root inline); /* 横方向 */

第1引数がスクローラー(nearest, root, self)、第2引数が軸(block, inline, x, y)です。
省略すると scroll(nearest block) になります。

注意点として、animation-durationauto にするか省略してください。
animation-timeline を使うときは時間ベースのdurationは無視されて、スクロール量が進行度を決めます。

実例1: スクロール進捗プログレスバー

ページの読了率を表示するプログレスバーを、CSSだけで実装してみます。
これが一番シンプルで分かりやすい例ですね。

<div class="progress-bar"></div>

<main>
  <h1>記事タイトル</h1>
  <p>ここにたくさんのコンテンツが入ります...</p>
  <!-- 長いコンテンツ -->
</main>
@keyframes scaleProgress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: #3b82f6;
  transform-origin: left;
  z-index: 9999;

  animation: scaleProgress linear both;
  animation-timeline: scroll(root);
}

これだけで、ページをスクロールするとバーが左から右に伸びていきます。
transform: scaleX() を使っているので、GPU描画になってパフォーマンスも良好です。

scroll(root) を指定しているのは、ルート要素(ページ全体)のスクロールに確実に連動させるためです。
ネストされたスクロールコンテナの影響を受けたくない場合は、明示的に root を指定しておくのがおすすめです。

実例2: 要素のフェードイン(view()を使う)

次は view() を使って、要素が画面に入ってきたときにフェードインさせてみます。
これまでIntersectionObserverでやっていた処理が、CSSだけで実現できます。

<div class="content">
  <section class="fade-in-section">
    <h2>セクション1</h2>
    <p>コンテンツがここに入ります。</p>
  </section>
  <section class="fade-in-section">
    <h2>セクション2</h2>
    <p>コンテンツがここに入ります。</p>
  </section>
  <section class="fade-in-section">
    <h2>セクション3</h2>
    <p>コンテンツがここに入ります。</p>
  </section>
</div>
@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.fade-in-section {
  animation: fadeInUp ease-out both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

ここで登場する animation-range が重要です。
これは「タイムラインのどの区間でアニメーションを実行するか」を指定するプロパティです。

animation-range: entry 0% entry 100% は、「要素がビューポートに入り始めてから完全に入りきるまで」の区間でアニメーションを実行する、という意味になります。

animation-range で使える主なキーワードは以下の通りです。

  • entry — 要素がビューポートに入る区間(入り始め → 完全に入る)
  • exit — 要素がビューポートから出る区間(出始め → 完全に出る)
  • cover — 要素がビューポートを覆う全区間(入り始め → 完全に出る)
  • contain — 要素がビューポートに完全に含まれている区間

例えば、入るときにフェードインして出るときにフェードアウトしたいなら、こう書きます。

@keyframes fadeInOut {
  0% {
    opacity: 0;
    transform: translateY(30px);
  }
  40% {
    opacity: 1;
    transform: translateY(0);
  }
  60% {
    opacity: 1;
    transform: translateY(0);
  }
  100% {
    opacity: 0;
    transform: translateY(-30px);
  }
}

.fade-in-out-section {
  animation: fadeInOut ease-in-out both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

cover を使うと、要素が見え始めてから完全に見えなくなるまでの全区間がアニメーション範囲になります。

実例3: パララックス風の演出

背景と前景で異なるスクロール速度を持たせる、パララックス風の演出もCSSだけで作れます。

<div class="parallax-container">
  <div class="parallax-bg"></div>
  <div class="parallax-content">
    <h2>パララックスセクション</h2>
    <p>背景がゆっくりスクロールします。</p>
  </div>
</div>
@keyframes parallaxBg {
  from {
    transform: translateY(-20%);
  }
  to {
    transform: translateY(20%);
  }
}

@keyframes parallaxContent {
  from {
    transform: translateY(10%);
  }
  to {
    transform: translateY(-10%);
  }
}

.parallax-container {
  position: relative;
  height: 80vh;
  overflow: hidden;
}

.parallax-bg {
  position: absolute;
  inset: -20% 0;
  background: url('/your-image.jpg') center / cover no-repeat;

  animation: parallaxBg linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

.parallax-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100%;
  color: #fff;

  animation: parallaxContent linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

仕組みとしては、背景画像は -20% から 20% に、コンテンツは 10% から -10% に移動します。
移動量が異なるので、スクロールすると背景とコンテンツが異なる速度で動くように見えます。

背景に inset: -20% 0 を指定して上下にはみ出させているのがポイントです。
これがないと、translateYで移動したときに隙間が見えてしまいます。

名前付きタイムライン

ここまでは匿名タイムライン(scroll()view())を使ってきましたが、名前付きタイムラインを使うと、別の要素のスクロール状態に連動させることもできます。

/* スクロールコンテナに名前を付ける */
.scroll-container {
  overflow-y: auto;
  scroll-timeline-name: --my-scroll;
  scroll-timeline-axis: block;
}

/* 別の要素でそのタイムラインを参照する */
.animated-element {
  animation: myAnimation linear both;
  animation-timeline: --my-scroll;
}

view-timeline も同じように名前付きで使えます。

/* 監視対象の要素に名前を付ける */
.watched-element {
  view-timeline-name: --card-view;
  view-timeline-axis: block;
}

/* その要素の表示状態に連動してアニメーションさせる */
.indicator {
  animation: highlight linear both;
  animation-timeline: --card-view;
  animation-range: contain 0% contain 100%;
}

名前付きタイムラインは、例えば「サイドバーの見出しインジケーター」のように、スクロール対象とアニメーション対象が異なるケースで活躍します。
タイムライン名は -- で始まるカスタムプロパティ的な命名規則です。

ブラウザ対応状況(2026年3月時点)

2026年3月現在の対応状況はこんな感じです。

  • Chrome / Edge — 115以降でフルサポート(2023年7月〜)
  • Safari — 26.0でサポート
  • Firefox — フラグを有効にすれば動作するが、デフォルトでは未対応

Chromium系 + Safariで対応済みなので、グローバルシェアで見ると約85%以上のブラウザで使えます。
Firefoxだけがまだデフォルトで有効になっていないのが惜しいところですが、Interop 2026の対象に含まれているので、今後の対応が期待できます。

プログレッシブエンハンスメントとして使うぶんには、もう十分実用的なレベルですね。

@supports でのフォールバック

非対応ブラウザへのフォールバックは @supports を使います。
基本的な考え方は「Scroll-Driven Animationsが使えるブラウザだけに適用し、非対応ブラウザではアニメーションなしの状態を見せる」です。

/* ベース: アニメーションなしの完成状態 */
.fade-in-section {
  opacity: 1;
  transform: translateY(0);
}

/* Scroll-Driven Animations対応ブラウザのみ */
@supports (animation-timeline: scroll()) {
  .fade-in-section {
    animation: fadeInUp ease-out both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

この書き方なら、非対応ブラウザでは要素が最初から表示された状態になり、対応ブラウザではスクロールに連動してフェードインします。
コンテンツが見えなくなってしまう事故を防げるので、この順番が大事です。

プログレスバーの場合はもっとシンプルにできます。

/* プログレスバーは対応ブラウザにだけ表示 */
.progress-bar {
  display: none;
}

@supports (animation-timeline: scroll()) {
  .progress-bar {
    display: block;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 4px;
    background: #3b82f6;
    transform-origin: left;
    z-index: 9999;
    animation: scaleProgress linear both;
    animation-timeline: scroll(root);
  }
}

非対応ブラウザではプログレスバー自体を表示しない、という割り切りですね。
あくまでエンハンスメントなので、これで十分です。

prefers-reduced-motion との併用もおすすめです。

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .fade-in-section {
      animation: fadeInUp ease-out both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

「アニメーションを減らす」設定のユーザーにはアニメーションを適用しない、というアクセシビリティへの配慮です。

JSライブラリとの使い分け

Scroll-Driven Animationsが使えるようになったとはいえ、JSライブラリが不要になったわけではありません。
用途によって使い分けるのがベストです。

CSS Scroll-Driven Animationsが向いているケース

  • プログレスバー、フェードイン、パララックスなどの定番演出
  • パフォーマンスが重要な場面(メインスレッドを使わない)
  • プログレッシブエンハンスメントとして使う場合
  • JSの依存を減らしたい場合

JSライブラリ(IntersectionObserver / GSAP等)が向いているケース

  • 特定のスクロール位置で一度だけクラスを付与したい(IntersectionObserver)
  • 複雑なシーケンスアニメーション(GSAP ScrollTrigger)
  • スクロール位置に応じてJSのロジックを実行したい
  • Firefox含む全ブラウザで確実に動かしたい場合
  • スクロール位置をピン留め(pin)して固定表示したい場合

個人的には、「見た目の演出」はCSS Scroll-Driven Animationsで、「ロジックの制御」はJSで、という切り分けがしっくり来ています。
フェードインやプログレスバーくらいならCSSで十分ですし、複雑なインタラクションが必要ならGSAPの出番です。

IntersectionObserverについては、「要素が見えたらクラスを付けてそのまま」というワンショットの処理には引き続き使いやすいですね。
CSSのScroll-Driven Animationsはスクロールに連動して常に動くので、用途が少し違います。

まとめ

CSS Scroll-Driven Animationsを使えば、JSなしでスクロール連動のアニメーションが作れます。

  • animation-timeline: scroll() でスクロール全体に連動
  • animation-timeline: view() で要素の表示に連動
  • animation-range でアニメーションの実行区間を細かく制御
  • @supports でフォールバックを書けば安心

2026年3月時点でChrome・Edge・Safariが対応済みで、実用には十分です。
プログレッシブエンハンスメントとして導入すれば、対応ブラウザではリッチな体験を、非対応ブラウザでは通常表示を提供できます。

スクロール連動の演出をJSで書いていた身としては、CSSだけで完結するのがめちゃくちゃ楽ですね。
コード量の少なさとパフォーマンスの良さに驚きました。

サンプルページで実際の動きを確認する

今回は以上です!