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)になりました。
| ブラウザ | 対応した版 |
|---|---|
| Chrome | 115 |
| Edge | 115 |
| Firefox | 159(2026年10月) |
| Safari | 26 |
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つです。
scroll(root block)はページ全体の縦スクロールをタイムラインにする。プログレスバー向きview()はその要素が画面のどこまで入ったかをタイムラインにする。要素ごとの表示演出向き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回目) | 120 | 159.3ms | 1.328ms | 0 |
| JS版(2回目) | 120 | 28.6ms | 0.238ms | 0 |
| JS版(3回目) | 120 | 37.7ms | 0.314ms | 0 |
| 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で決まらない演出です。移行の判断は、次の問いに順に答えると決まります。
- スクロールを戻したときに、見た目も戻ってよいか(よくないならJSに残す)
- 見た目はtransform・opacityなど、スクロールの割合で決まる値だけか(処理の呼び出しを含むならJS)
- 要素ごとに独立した演出か(多数の要素を順番に組み立てるならライブラリを残すか、WAAPIでまとめる)
- 対応していないブラウザで動かなくても、内容は読めるか(読めないならフォールバックを先に作る)
GSAPのScrollTriggerで組んだ、固定表示(pin)や長いタイムラインを使う演出は、無理に置き換えず残すのが現実的です。ScrollTriggerの組み方は模写上級 #004 | GSAPとScrollTriggerで実現するスクロール連動リバースアニメーションで、コピペで試せる例はGSAPアニメーションをコピペで使える無料ツール|ScrollTrigger対応100種で扱っています。IntersectionObserverとライブラリの使い分けはWebアニメーション完全ガイド|Animate.css・AOS・IO・GSAP 4手法を比較解説も参考になります。
移行のチェックリスト
1つの演出ずつ移し、移すたびに見た目と計測を確かめます。
- 移行する演出を表にして、「CSSへ移す」「JSに残す」を上の判断で分ける
- 移す前に、計測用の関数でscrollイベントの処理時間を取っておく
- @keyframesを書き、animation-timelineは一括指定の後に書く
- @supportsとprefers-reduced-motionの内側にだけアニメーションを書き、初期状態は見える状態にする
- 移したら、元のscrollイベントの処理を消す(残すと二重に動く)
getAnimations()でタイムラインが付いていること、計測でscrollイベントの処理が減ったことを確かめる- 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実装テクニック集【基礎・レイアウト・アニメーション・設計手法】から辿れます。





