この記事では、ページの読了率に連動するスクロール進捗バーを紹介します。CSSだけで書ける新しい方法(animation-timeline: scroll())と、全ブラウザで動く従来のJS(scroll イベント+requestAnimationFrame)を両方使い、対応・非対応のどちらでも動く形にします。コピペで動くHTML・CSS・JSとカスタマイズのポイントを解説します。
- CSSの
animation-timeline: scroll()でJSなしに進捗バーを動かす方法 - 未対応ブラウザ向けに、読了率を計算して動かすJSフォールバックの組み立て方
@supportsでCSSとJSを排他に切り替え、二重に動かさない設計- 進捗バーで動きを減らす設定にどう配慮するか(追従は残し補間だけ切る)
このスニペットで作れるもの
ページ最上部に固定したバーが、読み進めた割合(読了率)に連動して左から右へ伸びます。記事やLPの読了プログレス表示に使えるUIです。
動作の特徴は次の通りです。
- 読了率0%→100%に連動して、バーが左端から右端へ伸びる
- 対応ブラウザではCSSだけで動く(JSは一切関与しない)
- 未対応ブラウザでは、付属のJSが同じ進捗を計算してバーを動かす
- 動きを減らすOS設定が有効なときは、追従は残したまま余計な補間だけを止める
HTMLの構造を見てみよう
まずは進捗バー本体のHTMLを見てみましょう。ページ最上部に固定するコンテナと、その中で伸び縮みする子要素の2層構造です。
<div
class="c-progress"
data-scroll-progress
role="progressbar"
aria-label="ページの読了率"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<span class="c-progress__bar"></span>
</div>構造の要点は次の3つです。
- バーは「固定するコンテナ(トラック)」と「伸び縮みする子要素」の2層に分ける
data-scroll-progressを進捗バーを示すフック属性にするrole="progressbar"とaria-*で読了率を支援技術へ伝える
バーを2層に分ける理由とprogressbarロール
外側のコンテナ(.c-progress)は、ページ最上部への固定・トラックの色・高さといった「見た目の器」を担います。内側の子要素(.c-progress__bar)は、scaleX で 0→1 に伸びる「進捗の表現」だけを担います。器と動きの役割を分けておくと、対応・非対応のどちらの経路でも子要素の scaleX を動かすだけでよくなります。
data-scroll-progress は進捗バー専用のフック属性です。出現演出などで使う属性とは別系統にしているため、同じページに他のスクロール演出を同居させても干渉しません。
role="progressbar" と aria-valuemin/max/now は、読了率を支援技術へ伝えるためのものです。aria-valuenow の更新は、後述するJSフォールバックが動く環境でのみ行います。CSSだけで動く環境では、スクロール位置自体はブラウザや支援技術が把握しているため、値の常時更新は必須ではありません。
CSSだけでスクロール進捗に連動させる(animation-timeline)
ここが本記事の主役です。animation-timeline: scroll() を使うと、JSを一切書かずにスクロール位置とアニメーションを連動させられます。進捗バー本体に関わるCSSは次の通りです(デモ用の装飾は完成コードにまとめています)。
/* 伸び縮みするバー本体。
左端を起点に scaleX で 0 → 1 に伸ばすことで読了率を表現する。
width ではなく transform: scaleX を使うのは、レイアウトの
再計算を伴わず合成だけで完結し、スクロール追従が軽いため。 */
.c-progress__bar {
display: block;
width: 100%;
height: 100%;
background-color: var(--progress-color-accent);
transform: scaleX(0);
transform-origin: left center;
/* will-change は scaleX のみを対象にして、追従中の合成を最適化する。 */
will-change: transform;
}
/* スクロール進捗 0%→100% を scaleX(0)→scaleX(1) に対応させる。 */
@keyframes c-progress-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@supports (animation-timeline: scroll()) {
.c-progress__bar {
animation: c-progress-grow linear;
/* ルート(ページ全体)の縦スクロール進捗をタイムラインに使う。 */
animation-timeline: scroll(root block);
/* スクロールタイムラインでは duration はタイムラインの全長に
マッピングされるため秒数は意味を持たないが、ショートハンドの
省略時挙動のばらつきを避けるため明示しておく。 */
animation-duration: auto;
}
}scroll() がスクロール量をタイムラインに変換する
通常のアニメーションは「時間」が経つと進みます。これに対して scroll() は「スクロール位置」を進行軸にします。つまり、時間ではなくスクロール量に応じてアニメーションが進む仕組みです。
scroll(root block) の root はページ全体(最も外側のスクロールコンテナ)、block は縦方向を指します。ページの縦スクロール進捗が0%→100%とタイムラインに割り当てられ、それが c-progress-grow キーフレームの scaleX(0)→scaleX(1) に対応します。
スクロールタイムラインでは進行が時間ではなくスクロール位置で決まるため、animation-duration の秒数は意味を持ちません。ショートハンドの省略時挙動のばらつきを避けるため auto を明示しています。
width ではなく transform: scaleX で伸ばす理由
バーを伸ばすのに width を変えると、そのたびにレイアウトの再計算が走ります。transform: scaleX なら合成だけで完結するため、スクロールに追従する高頻度の更新でも軽く動きます。transform-origin: left で左端を起点に伸ばし、will-change: transform で追従中の合成を最適化しています。
全ブラウザで動かすためのJSフォールバック(@supports で両建て)
animation-timeline に未対応のブラウザでは、上の @supports ブロックが無効になり、バーは scaleX(0) のまま静止します。そこで、読了率を計算してCSSカスタムプロパティ --progress-value(0〜1)へ書き込み、CSS側がそれを scaleX に反映する経路を用意します。
CSSは @supports で実装を排他に切り替えます。
/* 未対応ブラウザ向け: JS が書き込む --progress-value(0〜1)を
scaleX に反映する。transition でわずかに補間し、scroll の
離散更新でも滑らかに見せる(reduced-motion 時はこの補間を切る)。 */
@supports not (animation-timeline: scroll()) {
.c-progress__bar {
transform: scaleX(var(--progress-value, 0));
transition: transform 0.1s var(--progress-easing);
}
}JS全体は次の通りです。
(function () {
"use strict";
const progress = document.querySelector("[data-scroll-progress]");
if (!progress) return;
// CSS の animation-timeline: scroll() が使える環境では CSS に任せ、
// JS は何もしない(二重駆動の防止)。
// CSS.supports 自体が無い極めて古い環境では JS フォールバックへ進む。
const cssDriven =
typeof window.CSS !== "undefined" &&
typeof window.CSS.supports === "function" &&
window.CSS.supports("animation-timeline", "scroll()");
if (cssDriven) return;
// ---- ここから JS フォールバック(animation-timeline 非対応環境)----
const root = document.documentElement;
// 多重 rAF 予約を防ぐフラグ。scroll が連続発火しても、
// 1 フレームにつき更新は 1 回だけにする。
let ticking = false;
/**
* 現在の読了率(0〜1)を計算する。
* 進捗率 = scrollTop / (scrollHeight - clientHeight)
* 分母が 0 以下(ページがビューポートに収まりスクロール不要)の場合は
* 0 除算を避け、進捗 0 を返す。
* @returns {number} 0〜1 にクランプした読了率
*/
function getProgress() {
const scrollTop = window.scrollY || root.scrollTop || 0;
const scrollable = root.scrollHeight - root.clientHeight;
// 0 除算ガード: スクロールできない(分母 0 以下)なら進捗 0。
if (scrollable <= 0) return 0;
const ratio = scrollTop / scrollable;
// 端での丸め誤差やバウンススクロールを考慮し 0〜1 にクランプ。
if (ratio < 0) return 0;
if (ratio > 1) return 1;
return ratio;
}
/**
* 読了率を CSS カスタムプロパティと ARIA 値へ反映する。
* 見た目(scaleX)の適用は CSS 側(--progress-value を読む)に委ねる。
*/
function update() {
const ratio = getProgress();
progress.style.setProperty("--progress-value", String(ratio));
progress.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
ticking = false;
}
/**
* scroll / resize ハンドラ。計算自体は rAF に遅延させ、
* フレーム同期で 1 回だけ update を走らせる(rAF スロットル)。
*/
function onScroll() {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(update);
}
// 初期表示時にも一度反映しておく(リロード時にスクロール位置が
// 途中だった場合に、最初から正しい進捗を見せる)。
update();
// passive: true で、リスナーがスクロールをブロックしないことを明示し、
// スクロール性能を確保する。
window.addEventListener("scroll", onScroll, { passive: true });
// ビューポートの高さやコンテンツ量が変わると分母が変わるため、
// resize でも再計算する。
window.addEventListener("resize", onScroll, { passive: true });
})();読了率の計算(0除算ガードとクランプ)
読了率は次の式で求めます。
進捗率 = スクロール量 ÷(ページ全体の高さ − 表示領域の高さ)
分母の「ページ全体の高さ − 表示領域の高さ」は、スクロールできる総量です。ページがビューポートに収まりスクロール不要のときは分母が0以下になるため、scrollable <= 0 を先にチェックして0を返し、0除算を防ぎます。
計算結果は、端での丸め誤差やバウンススクロールでわずかに範囲外になることがあるため、0 未満は0・1 超は1にクランプして0〜1に収めます。
scrollイベントを requestAnimationFrame で間引く(rAFスロットル)
scroll イベントはスクロール中に高頻度で発火します。発火のたびに計算すると重くなるため、requestAnimationFrame を使ってフレーム同期に間引きします。
仕組みは ticking フラグです。onScroll が呼ばれたとき、まだ更新を予約していなければ requestAnimationFrame(update) を1回だけ予約し、ticking を true にします。update が走り終えると ticking を false に戻します。これにより、連続発火しても1フレームにつき更新は1回だけになります。リスナーは passive: true で登録し、スクロール自体をブロックしないようにしています。
CSSとJSの二重駆動を防ぐ
CSSとJSの両方が同時にバーを動かすと、二重駆動になってしまいます。これを防ぐため、JSの冒頭で CSS.supports('animation-timeline', 'scroll()') を判定し、CSSだけで動く環境では scroll リスナーを一切張らずに早期 return します。
結果として、CSSが進捗を担う環境ではJSは何もせず、JSが動くのは未対応環境だけ、という役割分担になります。
動きを減らす設定への配慮(prefers-reduced-motion)
進捗バーは「どこまで読んだか」を示す情報提示UIです。そのため、動きを減らす設定が有効でも、非表示や満タン固定にはしません。そうすると読了率として誤った情報になってしまうためです。
このスニペットでは、スクロールに直接連動する追従そのものは残し、付随する補間(滑らかに動かす transition)だけを切ります。
@media (prefers-reduced-motion: reduce) {
@supports not (animation-timeline: scroll()) {
.c-progress__bar {
transition: none;
}
}
}CSS経路(animation-timeline)は、スクロール位置に直結した連動であり自動再生される動きではないため、追従はそのまま維持します。JS経路は補間 transition を none にして、各更新を即時反映します。
コピペで使うための完成コード
ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。
<!DOCTYPE html>
<html lang="ja" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>スクロール進捗バー</title>
<!--
JS が読み込まれる前に html の class を no-js → js へ書き換える。
head 内のインラインスクリプトで実行することで、画面描画前に切り替わる。
ただし、このスクロール進捗バーは出現演出と性質が異なる。
出現演出は「JS が要素を表示状態にしないとコンテンツが隠れたまま」に
なるため、JS 前提を no-js / js で厳密に切り分ける必要があった。
一方この進捗バーは、対応ブラウザでは CSS の animation-timeline: scroll()
だけでスクロールに連動して動くため、JS は「非対応ブラウザ向けの保険」
にすぎない。よって no-js / js を進捗バーの表示可否には使わない。
(no-js / js クラス自体は作法に揃えて付与しておく。)
-->
<script>
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
</script>
<link rel="stylesheet" href="./assets/css/style.css" />
</head>
<body>
<!--
スクロール進捗バー本体。
ページ最上部に position: fixed で固定し、ページの読了率
(スクロール進捗 0%→100%)に連動して左から右へ伸びる。
data-scroll-progress: 進捗バーであることを示すフック属性。
出現演出系の属性とは別系統にしているため、同じページに
出現演出を同居させても互いに干渉しない。
role="progressbar" / aria-* : 読了率を支援技術へ伝えるためのロール。
値(aria-valuenow)の更新は JS フォールバック時のみ行う
(CSS だけで動く対応ブラウザでは視覚的な進捗が主目的で、
スクロール位置自体はブラウザ/支援技術が把握しているため、
値の常時更新は必須ではない)。
-->
<div
class="c-progress"
data-scroll-progress
role="progressbar"
aria-label="ページの読了率"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<span class="c-progress__bar"></span>
</div>
<main class="l-main">
<section class="p-section">
<h1 class="p-section__title">スクロール進捗バー</h1>
<p class="p-section__text">
ページ最上部に固定したバーが、<strong>読み進めた割合(読了率)</strong>に
連動して左から右へ伸びる実装です。対応ブラウザでは
<code>animation-timeline: scroll()</code> によって
<strong>JS なし・CSS だけ</strong>でスクロール進捗に連動します。
この機能に未対応のブラウザでは、<code>scroll</code> イベントと
<code>requestAnimationFrame</code> を使った軽量な JS が同じ進捗を計算して
バーを更新します(フォールバック)。
OS の「視差効果を減らす」設定が有効な場合は、進捗に追従する動きを止めます。
下方向にスクロールして、バーが伸びていく様子を確認してください。
</p>
<!-- スクロール量を確保するためのダミーコンテンツ(デモ用)。
縦に十分長いセクションを並べ、読了率の変化を体感できるようにする。 -->
<div class="p-dummy">
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 1</h2>
<p class="p-dummy__text">
ここからスクロールを始めると、上部のバーが少しずつ右へ伸び始めます。
進捗率はページ全体に対する現在のスクロール位置の割合です。
</p>
</section>
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 2</h2>
<p class="p-dummy__text">
対応ブラウザでは、このバーの動きは CSS の
<code>animation-timeline: scroll()</code> がスクロール位置を
タイムラインとして扱うことで実現しています。JS は一切関与しません。
</p>
</section>
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 3</h2>
<p class="p-dummy__text">
未対応ブラウザでは、<code>scroll</code> イベントを
<code>requestAnimationFrame</code> でフレーム同期に間引きしながら、
読了率を計算してバーの幅(実際は <code>scaleX</code>)を更新します。
</p>
</section>
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 4</h2>
<p class="p-dummy__text">
CSS とJS は二重に動かないよう、JS 側で
<code>CSS.supports('animation-timeline', 'scroll()')</code> を判定し、
CSS で動く環境では JS はリスナーを張りません。
</p>
</section>
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 5</h2>
<p class="p-dummy__text">
ページの最下部に近づくにつれてバーは右端へ達し、読了率はおよそ
100% になります。短いページでは進捗の変化が分かりにくいため、
このデモでは縦に長いダミーコンテンツを置いています。
</p>
</section>
<section class="p-dummy__block">
<h2 class="p-dummy__heading">セクション 6</h2>
<p class="p-dummy__text">
最後まで読み終えると、バーは画面幅いっぱいまで伸びきります。
ここがページの終端です。
</p>
</section>
</div>
</section>
</main>
<script src="./assets/js/script.js" defer></script>
</body>
</html>/* ================================================
カスタムプロパティ
進捗バー専用のトークン名(--progress-*)でまとめる。
出現演出系(--fade-*)とは別系統のため、同じページに
両方を同居させてもトークンが衝突しない。
================================================ */
:root {
--progress-color-text: #2b2b2b;
--progress-color-text-muted: #555;
--progress-color-border: #e0e0e0;
--progress-color-bg: #ffffff;
--progress-color-accent: #f26b7a;
--progress-radius: 8px;
/* 進捗バーのトークン
--progress-height: バーの高さ(太さ)
--progress-track-bg: バーの下地(トラック)の色
--progress-easing: JS フォールバック時に、進捗更新を
わずかに滑らかにするイージング。
CSS の animation-timeline 経路では使わない。 */
--progress-height: 4px;
--progress-track-bg: rgba(242, 107, 122, 0.18);
--progress-easing: linear;
}
/* ================================================
ベースリセット(デモページ用)
================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Hiragino Sans", "Noto Sans JP", sans-serif;
color: var(--progress-color-text);
background-color: #fafafa;
line-height: 1.7;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.1em 0.35em;
background-color: #eef0f3;
border-radius: 4px;
}
/* ================================================
スクロール進捗バー本体
================================================ */
/* バーのコンテナ: ページ最上部に固定するトラック。
フックは data-scroll-progress(出現演出系の属性とは別系統)。
見た目(位置・色)はこのコンテナとして指定し、JS や対応有無に
依存させない。バーの「伸び」だけを子要素 __bar が担当する。 */
.c-progress {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 1000;
height: var(--progress-height);
background-color: var(--progress-track-bg);
/* バーがトラックからはみ出さないように。 */
overflow: hidden;
}
/* 伸び縮みするバー本体。
左端を起点に scaleX で 0 → 1 に伸ばすことで読了率を表現する。
width ではなく transform: scaleX を使うのは、レイアウトの
再計算を伴わず合成だけで完結し、スクロール追従が軽いため。 */
.c-progress__bar {
display: block;
width: 100%;
height: 100%;
background-color: var(--progress-color-accent);
transform: scaleX(0);
transform-origin: left center;
/* will-change は scaleX のみを対象にして、追従中の合成を最適化する。 */
will-change: transform;
}
/* ------------------------------------------------
第一実装: CSS だけでスクロール進捗に連動させる
(animation-timeline: scroll())
対応ブラウザでは、JS を一切使わずにバーがスクロール位置に連動する。
scroll() は最も近いスクロールコンテナ(ここではルート=
ページ全体)の縦スクロール進捗を 0%→100% のタイムラインとして
割り当てる。grow キーフレームを 0%→100% に貼ることで、
スクロール進捗がそのまま scaleX(0)→scaleX(1) に対応する。
------------------------------------------------ */
@keyframes c-progress-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@supports (animation-timeline: scroll()) {
.c-progress__bar {
animation: c-progress-grow linear;
/* ルート(ページ全体)の縦スクロール進捗をタイムラインに使う。 */
animation-timeline: scroll(root block);
/* スクロールタイムラインでは duration はタイムラインの全長に
マッピングされるため秒数は意味を持たないが、ショートハンドの
省略時挙動のばらつきを避けるため明示しておく。 */
animation-duration: auto;
}
}
/* ------------------------------------------------
JS フォールバック: animation-timeline 非対応ブラウザ向け
非対応環境では上の @supports ブロックが無効化され、バーは
scaleX(0) のまま静止する。JS が読了率を計算して
--progress-value(0〜1)を更新し、それを scaleX に反映する。
transition でわずかに補間し、scroll イベントの離散更新でも
滑らかに見せる(reduced-motion 時はこの transition を切る)。
------------------------------------------------ */
@supports not (animation-timeline: scroll()) {
.c-progress__bar {
transform: scaleX(var(--progress-value, 0));
transition: transform 0.1s var(--progress-easing);
}
}
/* ================================================
prefers-reduced-motion: 動きを減らす設定への配慮
進捗バーは「どこまで読んだか」を示す情報提示の役割を持つため、
非表示にも常時最終状態(満タン固定)にもしない(誤情報になる)。
ユーザーのスクロール操作に直接連動する追従そのものは残しつつ、
付随する補間(transition)だけを切って、滑走するような余計な
動きを出さないようにする。
- CSS 経路(animation-timeline): スクロール位置に直結した連動で
あり自動再生される動きではないため、追従自体は維持する。
- JS 経路: 進捗の補間 transition を none にし、各更新を即時反映する。
================================================ */
@media (prefers-reduced-motion: reduce) {
@supports not (animation-timeline: scroll()) {
.c-progress__bar {
transition: none;
}
}
}
/* ================================================
l-main / p-section: デモページのレイアウト
================================================ */
.l-main {
max-width: 720px;
margin-inline: auto;
padding: 40px 20px;
}
.p-section__title {
font-size: 22px;
font-weight: 700;
margin: 0 0 16px;
}
.p-section__text {
margin: 0 0 24px;
color: var(--progress-color-text-muted);
}
@media (min-width: 768px) {
.p-section__title {
font-size: 28px;
}
}
/* ================================================
p-dummy: スクロール量を確保するデモ用ダミーコンテンツ
読了率の変化を体感できるよう、ビューポートより十分に長く
なる縦長のセクションを並べる。スニペット本体ではない。
================================================ */
.p-dummy {
display: flex;
flex-direction: column;
gap: 16px;
}
.p-dummy__block {
/* 各ブロックを縦に長く取り、ページ全体のスクロール量を確保する。 */
min-height: 80vh;
padding: 24px;
background-color: var(--progress-color-bg);
border: 1px solid var(--progress-color-border);
border-left: 4px solid var(--progress-color-accent);
border-radius: var(--progress-radius);
}
.p-dummy__heading {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.p-dummy__text {
margin: 0;
color: var(--progress-color-text-muted);
}/**
* ページの読了率(スクロール進捗 0%→100%)に連動して、最上部に固定した
* バーを左から右へ伸ばすスクロール進捗バー。
*
* 設計方針:
* - 第一実装は CSS の animation-timeline: scroll()。対応ブラウザでは
* この JS は何もしない(CSS だけでスクロールに連動する)。
* - この JS は animation-timeline 非対応ブラウザ向けの「フォールバック」
* 専任。読了率を計算し、CSS カスタムプロパティ --progress-value(0〜1)
* へ書き込む。CSS 側がそれを scaleX に反映する。
*
* 二重駆動の防止:
* - CSS.supports('animation-timeline', 'scroll()') が true の環境では、
* CSS が進捗を担うため scroll リスナーを一切張らない。
* こうして「CSS と JS が同時にバーを動かす」二重駆動を防ぐ。
*
* パフォーマンス:
* - scroll イベントは高頻度で発火するため、毎回計算せず
* requestAnimationFrame でフレーム同期に間引きする(rAF スロットル)。
* ticking フラグで多重 rAF 予約を防ぎ、1 フレームに 1 回だけ更新する。
*
* アクセシビリティ:
* - prefers-reduced-motion: reduce のときは、CSS 側で補間 transition を
* 切る。JS は読了率の値を更新するだけ(追従はユーザー操作に直結する
* 動きのため維持し、滑走するような余計な補間だけを止める)。
*/
(function () {
"use strict";
const progress = document.querySelector("[data-scroll-progress]");
if (!progress) return;
// CSS の animation-timeline: scroll() が使える環境では CSS に任せ、
// JS は何もしない(二重駆動の防止)。
// CSS.supports 自体が無い極めて古い環境では JS フォールバックへ進む。
const cssDriven =
typeof window.CSS !== "undefined" &&
typeof window.CSS.supports === "function" &&
window.CSS.supports("animation-timeline", "scroll()");
if (cssDriven) return;
// ---- ここから JS フォールバック(animation-timeline 非対応環境)----
const root = document.documentElement;
// 多重 rAF 予約を防ぐフラグ。scroll が連続発火しても、
// 1 フレームにつき更新は 1 回だけにする。
let ticking = false;
/**
* 現在の読了率(0〜1)を計算する。
* 進捗率 = scrollTop / (scrollHeight - clientHeight)
* 分母が 0 以下(ページがビューポートに収まりスクロール不要)の場合は
* 0 除算を避け、進捗 0 を返す。
* @returns {number} 0〜1 にクランプした読了率
*/
function getProgress() {
const scrollTop = window.scrollY || root.scrollTop || 0;
const scrollable = root.scrollHeight - root.clientHeight;
// 0 除算ガード: スクロールできない(分母 0 以下)なら進捗 0。
if (scrollable <= 0) return 0;
const ratio = scrollTop / scrollable;
// 端での丸め誤差やバウンススクロールを考慮し 0〜1 にクランプ。
if (ratio < 0) return 0;
if (ratio > 1) return 1;
return ratio;
}
/**
* 読了率を CSS カスタムプロパティと ARIA 値へ反映する。
* 見た目(scaleX)の適用は CSS 側(--progress-value を読む)に委ねる。
*/
function update() {
const ratio = getProgress();
progress.style.setProperty("--progress-value", String(ratio));
progress.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
ticking = false;
}
/**
* scroll / resize ハンドラ。計算自体は rAF に遅延させ、
* フレーム同期で 1 回だけ update を走らせる(rAF スロットル)。
*/
function onScroll() {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(update);
}
// 初期表示時にも一度反映しておく(リロード時にスクロール位置が
// 途中だった場合に、最初から正しい進捗を見せる)。
update();
// passive: true で、リスナーがスクロールをブロックしないことを明示し、
// スクロール性能を確保する。
window.addEventListener("scroll", onScroll, { passive: true });
// ビューポートの高さやコンテンツ量が変わると分母が変わるため、
// resize でも再計算する。
window.addEventListener("resize", onScroll, { passive: true });
})();カスタマイズしたい場面でよく触るのは次のポイントです。
- バーの色:
--progress-color-accent - トラック(下地)の色:
--progress-track-bg - バーの太さ:
--progress-height - JS補間のイージング:
--progress-easing - 参照するスクロール対象:
animation-timeline: scroll()の対象(既定はページ全体)
よくある質問
animation-timeline: scroll()に対応していないブラウザでは動かないのですか?動きます。対応ブラウザではCSSだけでバーが動き、未対応ブラウザでは付属のJavaScriptがスクロール量から読了率を計算してバーを更新します。
@supportsでCSSの実装を切り替え、JS側も対応状況を判定するため、対応・非対応のどちらでも同じ進捗バーが機能します。
- CSSとJSが同時に動いて、バーが二重に動くことはありませんか?
ありません。JavaScript側で
CSS.supports('animation-timeline', 'scroll()')を判定し、CSSだけで動く環境ではスクロールのリスナーを張りません。CSSが担う環境ではJSは何もせず、JSが担うのは未対応環境だけ、という役割分担で二重駆動を防いでいます。
- 動きを減らすOS設定が有効なとき、進捗バーは消した方がいいですか?
消さない方が自然です。進捗バーは「どこまで読んだか」を示す情報なので、非表示や満タン固定にすると誤った情報になります。このスニペットでは、スクロールに直接連動する追従は残したまま、余計な補間(滑らかに動かす transition)だけを切る形にしています。
まとめ
CSSの animation-timeline: scroll() と従来のJSで両対応するスクロール進捗バーを紹介しました。要点は次の通りです。
- 対応ブラウザはCSSの
animation-timeline: scroll()だけで動く(JS不要) - 未対応ブラウザは
scrollイベント+requestAnimationFrameのJSで同じ進捗を計算する @supportsでCSSを排他に切り替え、JSはCSS.supports判定で二重駆動を防ぐ- 進捗バーは情報提示UIなので、動きを減らす設定でも追従は残し補間だけ切る
まずはコピペして動かし、対応ブラウザではJSなしで動くこと、--progress-color-accent で色を変えられることを確認してみてください。
【関連記事】
