CSS Scroll-Driven Animationsで進捗バーのJSは消せた。view()への移行はキルスイッチに阻まれた
clvr.lolのスクロール進捗バーをanimation-timeline: scroll()に置き換えてJSを削った実録。view()でのフェードイン移行は演出キルスイッチとの相性で見送り。@supportsとprefers-reduced-motionを含む実装例つき。
このブログは動きすぎなくらい動く。背景では星がゆっくり漂い、画面全体にうっすらスキャンラインがかかり、ロゴは数秒おきに勝手にグリッチする。そういうシンセウェイブ趣味のサイトなので、ページ上部のネオン色のスクロール進捗バーも当然のようにある。これは長らくJavaScriptで実装していた。
const bar = document.getElementById('scroll-neon');
document.addEventListener('scroll', () => {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
bar.style.width = (max > 0 ? (h.scrollTop / max) * 100 : 0) + '%';
}, { passive: true });
passiveだし別に重くもない。でも、CSSのanimation-timeline: scroll()を使えばこれがJSゼロで書けると知って、置き換えてみることにした。結果、進捗バーは移行して満足、フェードインのview()移行は途中でやめた。その顛末を書く。
進捗バー: scroll()でリスナーが1本消えた
Scroll-Driven Animationsは、@keyframesの進行を「時間」ではなく「スクロール位置」に紐づける仕組みだ。ページ全体のスクロール量ならanimation-timeline: scroll()を指定するだけでいい。
#scroll-neon {
position: fixed; top: 0; left: 0;
width: 100%; height: 2px;
background: linear-gradient(to right, var(--c-cyan), var(--c-purple), var(--c-pink));
transform-origin: left;
transform: scaleX(0);
animation: neon-progress linear;
animation-timeline: scroll();
}
@keyframes neon-progress {
to { transform: scaleX(1); }
}
JS版から変えたのはwidthではなくtransform: scaleX()を動かすようにした点。widthのアニメーションはレイアウトを毎回叩くが、transformはコンポジタで完結するので、どうせ書き直すならこちらが筋がいい。挙動は完全に同等で、スクロールのたびにstyle.widthへ文字列を書き込むコードが丸ごと消えた。この置き換えに関しては迷う要素がなかった。
view()のフェードインは「同じもの」にならない
味をしめて、次はスクロールリベール(カードが画面に入るとふわっと出てくるやつ)を狙った。現状はIntersectionObserverでis-visibleクラスを付けてCSS transitionを走らせる、20行くらいの定番実装だ。CSS版はこうなる。
.reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal-up {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; }
}
書き換え自体は数分で終わった。ただ、動かしてみるとこれは同じ演出ではない。IntersectionObserver版は「画面に入った瞬間をトリガーに、0.6秒かけて一方通行で再生」される。view()版はスクロール量でスクラブされるので、ゆっくりスクロールすると要素がじわじわ半透明のまま付いてきて、上に戻すとまた消えていく。animation-rangeをentry 0% entry 60%のように詰めれば体感は近づくが、「時間で再生」が「位置に張り付く」に変わったこと自体は消せない。どちらが良いかは演出の好みで、スクラブ感が合うサイトも普通にあると思う。うちの場合は、戻りスクロールで本文カードが消えるのが読み物として鬱陶しかった。
見送りの決め手はFXキルスイッチだった
それでも調整すれば使えるかと思っていたところで、致命的な相性問題に気づいた。このサイトにはFキー一発で全演出を止めるキルスイッチがあって、実装はこの1行に依存している。
body.fx-off * { animation-play-state: paused !important; }
星もグリッチも点滅カーソルも、全部「アニメーション」だからこれで一括停止できる。一方、既存のリベールはCSS transitionなのでanimation-play-stateの影響を受けず、FXを切っても本文は普通に表示される。ところがリベールをview()に移行すると、それも「アニメーション」の仲間入りをする。手元のChromeで試すと、pausedはスクロールタイムラインにも効いて、その時点の進捗で凍結する。つまりFXオフを押した瞬間に、画面下のほうにいたカードが半透明のままフリーズして、スクロールしても二度と現れない。装飾が凍るのは構わないが、本文が読めなくなるのはダメだ。
除外セレクタを足せば回避はできる。ただ「!important一発で例外なく全部止まる」という単純さがこの仕組みの取り柄なので、例外リストを育て始めた時点で負けだと判断して、リベールはIntersectionObserverのまま残した。進捗バーのほうも同じ理屈で凍るが、こちらはbody.fx-off #scroll-neon { display: none; }を1行足して非表示に倒した。バーが消えても誰も困らない。
対応状況とフォールバックの書き方(執筆時点)
対応状況は、ChromeとEdgeが115から、Safariが26から。Firefoxは執筆時点(2026年4月)の手元の安定版ではlayout.css.scroll-driven-animations.enabledフラグを立てないと動かなかった。この辺りは動きが速いので、導入前にcaniuseを見てほしい。
未対応ブラウザ対策で一つだけ罠がある。opacity: 0を素のクラスに書くと、未対応環境でアニメーションが始まらず本文が永久に見えない。初期状態を隠す指定は必ず@supportsの内側に置く。ついでにprefers-reduced-motionもここで一緒に処理できる。
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.scroll-fade {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
}
この書き方なら、未対応ブラウザとreduced-motion設定のユーザーには「最初から表示」というフォールバックが、追加コードなしで手に入る。うちは星の漂いもグリッチもreduced-motionで止めているので、リベールだけ動く理由もない。
移行して分かったこと
Scroll-Driven Animationsの紹介記事はだいたい「JSが24行から9行になる」という話をする。それは嘘ではないが、実際に手を動かして時間を食ったのは行数の話ではなく、既存の演出制御(キルスイッチ・reduced-motion分岐)とどう噛み合うかだった。スクロールイベントのハンドラを減らすこと自体はメインスレッドに優しく、Core Web Vitals改善の文脈でも素直に正しい。ただ「アニメーションになる」ということは、サイト内のアニメーション一括制御すべての管轄下に入るということでもある。
まず試すなら進捗バーからというのは自分の経験からも同意で、挙動が完全に等価なうえ削れるコードが自己完結している。逆にトリガー型の出現演出を置き換えるときは、スクラブ挙動が演出として許容できるかを先に確かめたほうがいい。このサイトがどれくらい常時動いているかは、ゲーム置き場あたりを開いてもらうのが早い。あの物量の演出と共存させる前提だと、新しいCSS機能は「書けるか」より「止められるか」で評価することになる。