JSのスクロール演出をanimation-timelineへ移す判断と計測


animation-timelineとは、CSSアニメーションの進み具合を時間ではなくスクロールの位置や要素の見え方に結びつけるCSSのプロパティです。2026年10月にFirefox 159が既定で有効にし、Chrome・Safariとあわせて主要なブラウザすべてで動くBaseline(Newly available)になりました。

これまでスクロールに合わせた演出は、scrollイベントやIntersectionObserverでJavaScriptから書くのが普通でした。CSSだけで書けるようになった今、「どの演出をCSSに移し、どれをJSに残すか」「移したら何が変わるのか」「効かないときにどこを見るか」を決めておかないと、移行が中途半端になります。

この記事では、JSで書いたプログレスバーとカードの表示演出をanimation-timelineに書き換え、スクロール中に動くスクリプトの量を同じ条件で比べました。つまずきやすい2つの書き方も手元で再現しています。確認は2026年11月9日、macOSのChrome 156で行いました。ブラウザの対応状況はweb.devの「New to the web platform in October」(2026年10月29日)、仕様の細部はMDNのanimation-timelineのページ(2026年9月16日更新)を同じ日に確認しています。


結論:transformとopacityの演出はCSSへ、状態を持つ演出はJSに残す

スクロールの位置に比例して動くだけの演出は、animation-timelineに移すのが基本です。一度出したら戻さない演出や、スクロールで処理を呼び出す演出はJSに残します。

演出移す先理由
読んだ量のプログレスバーCSS(scroll())スクロール量そのものが進み具合になる
画面に入った要素のフェード・スライドCSS(view())見え方の割合が進み具合になる
パララックス(背景をゆっくり動かす)CSS(view() か scroll())transformだけで書ける
一度表示したら戻さない演出JS(IntersectionObserver+クラス)animation-timelineはスクロールを戻すと逆再生される
画面に入ったら時間で再生する演出JS(IntersectionObserver)進み具合が時間で決まるため
スクロールで計測・読み込み・状態の変更をするJSアニメーションではなく処理の呼び出し
複数の要素を順番に組み立てる長い演出JSのアニメーションライブラリ(GSAPなど)を残すか、WAAPIのViewTimelineへCSSだけで書くと指定が増えて保守しにくい

スクロールを戻すと逆再生されることは、この記事の確認で実際に見ています。2枚目のカードの不透明度は、ページの先頭で0.27、下へスクロールすると1.00、先頭へ戻すと再び0.27になりました。「戻しても消えない」が要件なら、その演出はCSSに移しません。


animation-timelineの対応ブラウザ(2026年11月時点)

web.devの2026年10月の記事によると、scroll-driven animationsはFirefox 159の既定有効化で主要ブラウザがそろい、Baseline(Newly available)になりました。

ブラウザ対応した版
Chrome115
Edge115
Firefox159(2026年10月)
Safari26

Newly availableは「主要ブラウザの最新版で動く」という段階で、古い版のブラウザを使う人はまだ残ります。そのため、移行するときは対応していないブラウザでも内容が読める書き方(後の「フォールバック」の節)にしておきます。


JSのスクロール演出をanimation-timelineに書き換える

書き換えでやることは、JSで計算していた「進み具合」を、CSSのタイムラインに任せることです。@keyframesは今までどおり書き、animation-timelineで時間の代わりに使うタイムラインを指定します。

書き換える前のJS

よくある書き方です。scrollイベントのたびに、ページ全体の進み具合からバーの幅を、各カードの位置から不透明度と位置を計算しています。

const bar = document.querySelector('.progress');
const cards = [...document.querySelectorAll('.card')];

function update() {
  const max = document.documentElement.scrollHeight - innerHeight;
  bar.style.transform = `scaleX(${scrollY / max})`;
  for (const card of cards) {
    const top = card.getBoundingClientRect().top;
    const p = Math.min(Math.max((innerHeight - top) / (innerHeight * 0.3), 0), 1);
    card.style.opacity = p;
    card.style.transform = `translateY(${40 * (1 - p)}px)`;
  }
}

addEventListener('scroll', update, { passive: true });
update();

animation-timelineで書いたCSS

同じ見た目をCSSだけで書くと次のようになります。HTMLは要素を並べるだけで、スクリプトは要りません。

.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 6px;
  background: #2563eb;
  transform-origin: 0 50%;
  transform: scaleX(0);
}

.card {
  margin: 40vh auto;
  width: min(90%, 560px);
  padding: 32px;
  background: #f1f5f9;
  border-radius: 12px;
}

@keyframes grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

@keyframes reveal {
  from { opacity: 0; transform: translateY(40px); }
  to { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .progress {
      animation: grow linear both;
      animation-timeline: scroll(root block);
    }

    .card {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}
<div class="progress"></div>
<div class="card">カード1</div>
<div class="card">カード2</div>
<div class="card">カード3</div>

書き換えのポイントは3つです。

  1. scroll(root block) はページ全体の縦スクロールをタイムラインにする。プログレスバー向き
  2. view() はその要素が画面のどこまで入ったかをタイムラインにする。要素ごとの表示演出向き
  3. animation-range: entry 0% cover 30% で、要素が画面に入り始めてから、要素の通り道の30%まで進むあいだにアニメーションを終える

Chrome 156で開くと、ページの先頭では、画面の下端にかかっている2枚目のカードが不透明度0.27、まだ画面の外にある3枚目が0でした。ページの4分の1までスクロールすると、バーの transform は scaleX(0.25) 相当になりました。

JSから付けたいときはViewTimelineを使う

あとから追加される要素や、条件によって演出を付け外ししたい要素は、Web Animations API(WAAPI)からも同じタイムラインを使えます。CSS版と同じ仕組みで動くので、scrollイベントの処理は要りません。

const el = document.querySelector('.card');

if ('ViewTimeline' in window) {
  el.animate(
    { opacity: [0, 1], transform: ['translateY(40px)', 'none'] },
    {
      fill: 'both',
      timeline: new ViewTimeline({ subject: el }),
      rangeStart: 'entry 0%',
      rangeEnd: 'cover 30%',
    }
  );
}

このコードもChrome 156で動かしました。要素が画面の外にある間は進み具合0(不透明度0)で、画面に入ってくると0.77(不透明度0.77)、さらに進むと1(不透明度1)になりました。Chrome公式の事例紹介でも、JSのAPIとCSSは同じ仕組みで動くと説明されています。


計測:スクロール中に動くスクリプトの量を比べる

JS版はスクロールのたびに処理が呼ばれ、CSS版はスクロールで呼ばれる処理がありません。同じページで、プログラムから120回に分けて最後までスクロールし、scrollイベントの処理に使った時間を数えました。

測り方

元の処理の前後で時間を足し込むようにしておき、計測用の関数をDevToolsのコンソールから呼びます。

function update() {
  const t0 = performance.now();
  // ここに元の処理
  window.handlerCalls += 1;
  window.handlerMs += performance.now() - t0;
}
async function measure(steps = 120) {
  window.handlerCalls = 0;
  window.handlerMs = 0;
  const max = document.documentElement.scrollHeight - innerHeight;
  for (let i = 1; i <= steps; i++) {
    await new Promise((r) => requestAnimationFrame(r));
    scrollTo(0, (max * i) / steps);
  }
  return {
    calls: window.handlerCalls,
    totalMs: window.handlerMs.toFixed(2),
    perCallMs: (window.handlerMs / Math.max(window.handlerCalls, 1)).toFixed(3),
    animations: document.getAnimations().length,
  };
}

scrollTo(0, 0);
setTimeout(() => measure().then(console.table), 300);

結果

呼ばれた回数処理の合計1回あたりページ上のアニメーション
JS版(1回目)120159.3ms1.328ms0
JS版(2回目)12028.6ms0.238ms0
JS版(3回目)12037.7ms0.314ms0
CSS版0(scrollイベントの処理なし)0ms—9(ScrollTimeline 1・ViewTimeline 8)

JS版は、カード8枚・バー1本の小さなページでも、スクロールのたびに要素の位置を読み、スタイルを書き換えています。1回目は合計159.3msで、2回目・3回目の4〜6倍でした。CSS版は document.getAnimations() で9個のアニメーションがスクロールのタイムラインにつながっていることを確かめ、スクロール中に呼ばれる自分のスクリプトは0でした。

この数字は手元のMacのChrome 156で測ったもので、端末が遅いほどJS版の1回あたりの時間は伸びます。自分のサイトで判断するときは、同じ関数を移行前のページで動かし、要素の数が多いページや性能の低い端末で数字を取るのが確実です。

メインスレッドが重いときの違いは、Chrome for Developersの事例紹介(A case study on scroll-driven animations performance)に比較があります。重い計算を入れると従来のJS版はカクつき、CSS版は影響を受けずにスクロールに合わせて動いた、という内容です。同じ記事には、この性能面の改善はChrome 116以降で使えるとも書かれています。


対応していないブラウザと「動きを減らす」設定への備え

アニメーションの指定は@supportsとprefers-reduced-motionの内側にだけ書き、外側では最初から内容が見える状態にしておきます。上のCSSはこの形で書いています。

  • カードの初期状態(.card)に opacity: 0 を書かない。対応していないブラウザでは@supportsの中が読まれないので、そのまま見える
  • @supports (animation-timeline: view()) で、対応しているブラウザだけにアニメーションを付ける
  • @media (prefers-reduced-motion: no-preference) で、OSの設定で動きを減らすことを選んでいる人には動きを付けない

JS版では、初期状態をCSSで opacity: 0 にしておき、スクリプトで見えるようにしていました。この書き方は、スクリプトが読み込まれない・エラーで止まったときに、内容が見えないまま残ります。CSSへ移すときに、初期状態を「見える」に寄せておくのが安全です。

この記事の確認は、動きを減らす設定をしていない状態(prefers-reduced-motion: no-preference)で行いました。


animation-timelineが効かないときの切り分け

効かないときは、まず要素にどのタイムラインが付いているかをコンソールで確かめます。見た目だけで原因を探すより早く分かります。

const el = document.querySelector('.card');
const cs = getComputedStyle(el);
const anim = el.getAnimations()[0];

console.log({
  timeline: cs.animationTimeline,
  type: anim?.timeline?.constructor.name,
  source: anim?.timeline?.source?.nodeName,
  progress: anim?.effect.getComputedTiming().progress,
});

正しく付いていれば、type は ScrollTimeline か ViewTimeline になり、progress がスクロールに合わせて変わります。手元で再現できたつまずきは次の2つです。

animationの一括指定を後に書いている

MDNによると、animation の一括指定はanimation-timelineを auto に戻します。そのため、animation-timelineは一括指定より後に書きます。

.bad {
  animation-timeline: scroll();
  animation: grow linear both;
}

.good {
  animation: grow linear both;
  animation-timeline: scroll();
}

手元で確かめると、.bad は animation-timeline の計算値が auto になり、タイムラインは通常の時間(DocumentTimeline)でした。時間は指定していないので、読み込んだ瞬間に最後の状態(バーが全幅)で止まります。.good は scroll() のまま、ページの中ほどで進み具合0.5になりました。

途中の親にoverflow: hiddenがある

scroll() は既定でいちばん近いスクロールコンテナを使います。途中の親に overflow: hidden があると、その親がスクロールコンテナになり、ページをスクロールしても進みません。

手元で、高さ60pxの overflow: hidden の箱の中に置いた要素(position: fixed ではない普通の要素)は、タイムラインの元がその箱(DIV)になり、進み具合は null(動いていない)でした。同じ箱を overflow: clip に変えると、元はページ全体(HTML)になり、ページの中ほどで0.5まで進みました。同じ箱の中の要素に scroll(root) を指定した場合も、元はページ全体(HTML)になり0.5まで進みました。はみ出しを隠したいだけなら overflow: clip にするか、scroll(root) でページ全体を指定します。

ほかに確認したい点として、プロパティの書き間違いや、@supportsの条件に合っていないことがあります。


JSやGSAPを残す判断

CSSに移さないほうがよいのは、スクロールの位置と見た目が1対1で決まらない演出です。移行の判断は、次の問いに順に答えると決まります。

  1. スクロールを戻したときに、見た目も戻ってよいか(よくないならJSに残す)
  2. 見た目はtransform・opacityなど、スクロールの割合で決まる値だけか(処理の呼び出しを含むならJS)
  3. 要素ごとに独立した演出か(多数の要素を順番に組み立てるならライブラリを残すか、WAAPIでまとめる)
  4. 対応していないブラウザで動かなくても、内容は読めるか(読めないならフォールバックを先に作る)

GSAPのScrollTriggerで組んだ、固定表示(pin)や長いタイムラインを使う演出は、無理に置き換えず残すのが現実的です。ScrollTriggerの組み方は模写上級 #004 | GSAPとScrollTriggerで実現するスクロール連動リバースアニメーションで、コピペで試せる例はGSAPアニメーションをコピペで使える無料ツール|ScrollTrigger対応100種で扱っています。IntersectionObserverとライブラリの使い分けはWebアニメーション完全ガイド|Animate.css・AOS・IO・GSAP 4手法を比較解説も参考になります。


移行のチェックリスト

1つの演出ずつ移し、移すたびに見た目と計測を確かめます。

  1. 移行する演出を表にして、「CSSへ移す」「JSに残す」を上の判断で分ける
  2. 移す前に、計測用の関数でscrollイベントの処理時間を取っておく
  3. @keyframesを書き、animation-timelineは一括指定の後に書く
  4. @supportsとprefers-reduced-motionの内側にだけアニメーションを書き、初期状態は見える状態にする
  5. 移したら、元のscrollイベントの処理を消す(残すと二重に動く)
  6. getAnimations() でタイムラインが付いていること、計測でscrollイベントの処理が減ったことを確かめる
  7. Firefox・Safariでも見た目を確かめる

よくある質問

Q. animation-timelineはどのブラウザで使えますか?

Chrome・Edgeは115、Safariは26、Firefoxは2026年10月の159から既定で使えます。主要ブラウザがそろい、web.devでBaseline(Newly available)とされています。

Q. スクロールを戻すとアニメーションも戻ります。一度だけ再生できますか?

animation-timelineは進み具合がスクロールの位置で決まるので、戻すと逆再生されます。一度だけ再生したい演出は、IntersectionObserverでクラスを付ける従来の方法を使ってください。

Q. animation-timelineを書いたのに、読み込んだ瞬間に最後の状態で止まります。

animationの一括指定をanimation-timelineより後に書いていると、タイムラインがautoに戻ります。animation-timelineを一括指定の後に書き直してください。

Q. scroll()を指定してもページのスクロールで動きません。

途中の親にoverflow: hiddenがあると、その親がスクロールコンテナになります。overflow: clipに変えるか、scroll(root)でページ全体を指定してください。

Q. 対応していないブラウザではどうなりますか?

@supportsの内側にアニメーションを書き、初期状態を見える状態にしておけば、動きが付かないだけで内容はそのまま読めます。

Q. GSAPのScrollTriggerは使わなくてよくなりますか?

スクロールの位置に比例して動くだけの演出はCSSに移せます。固定表示や長いタイムライン、スクロールで処理を呼ぶ演出はScrollTriggerを残すのが現実的です。


次に読む

JSのIntersectionObserverでフェードインを書く従来の方法はスクロールアニメーションを実装|JavaScriptとCSSで簡単にで、@keyframesの書き方そのものはCSSアニメーション徹底解説|@keyframes の基本から応用までで扱っています。移した演出の動きの候補を探すときはCSSアニメーション サンプル集が使えます。CSSの記事全体はCSS実装テクニック集【基礎・レイアウト・アニメーション・設計手法】から辿れます。


関連記事