この記事では、ページの読了率に連動するスクロール進捗バーを紹介します。CSSだけで書ける新しい方法(animation-timeline: scroll())と、全ブラウザで動く従来のJS(scroll イベント+requestAnimationFrame)を両方使い、対応・非対応のどちらでも動く形にします。コピペで動くHTML・CSS・JSとカスタマイズのポイントを解説します。

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • CSSの animation-timeline: scroll() でJSなしに進捗バーを動かす方法
  • 未対応ブラウザ向けに、読了率を計算して動かすJSフォールバックの組み立て方
  • @supports でCSSとJSを排他に切り替え、二重に動かさない設計
  • 進捗バーで動きを減らす設定にどう配慮するか(追従は残し補間だけ切る)

このスニペットで作れるもの

ページ最上部に固定したバーが、読み進めた割合(読了率)に連動して左から右へ伸びます。記事やLPの読了プログレス表示に使えるUIです。

動作の特徴は次の通りです。

  • 読了率0%→100%に連動して、バーが左端から右端へ伸びる
  • 対応ブラウザではCSSだけで動く(JSは一切関与しない)
  • 未対応ブラウザでは、付属のJSが同じ進捗を計算してバーを動かす
  • 動きを減らすOS設定が有効なときは、追従は残したまま余計な補間だけを止める

HTMLの構造を見てみよう

まずは進捗バー本体のHTMLを見てみましょう。ページ最上部に固定するコンテナと、その中で伸び縮みする子要素の2層構造です。

JavaScript
<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は次の通りです(デモ用の装飾は完成コードにまとめています)。

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;
  }
}
OPEN

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 で実装を排他に切り替えます。

CSS
/* 未対応ブラウザ向け: 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全体は次の通りです。

JavaScript
(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 });
})();
OPEN

読了率の計算(0除算ガードとクランプ)

読了率は次の式で求めます。

進捗率 = スクロール量 ÷(ページ全体の高さ − 表示領域の高さ)

分母の「ページ全体の高さ − 表示領域の高さ」は、スクロールできる総量です。ページがビューポートに収まりスクロール不要のときは分母が0以下になるため、scrollable <= 0 を先にチェックして0を返し、0除算を防ぎます。

計算結果は、端での丸め誤差やバウンススクロールでわずかに範囲外になることがあるため、0 未満は0・1 超は1にクランプして0〜1に収めます。

scrollイベントを requestAnimationFrame で間引く(rAFスロットル)

scroll イベントはスクロール中に高頻度で発火します。発火のたびに計算すると重くなるため、requestAnimationFrame を使ってフレーム同期に間引きします。

仕組みは ticking フラグです。onScroll が呼ばれたとき、まだ更新を予約していなければ requestAnimationFrame(update) を1回だけ予約し、tickingtrue にします。update が走り終えると tickingfalse に戻します。これにより、連続発火しても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)だけを切ります。

CSS
@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> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。

HTML
<!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>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   進捗バー専用のトークン名(--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);
}
OPEN
JavaScript
/**
 * ページの読了率(スクロール進捗 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 });
})();
OPEN

カスタマイズしたい場面でよく触るのは次のポイントです。

  • バーの色: --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 で色を変えられることを確認してみてください。

関連記事