この記事では、ページの読了率に連動するスクロール進捗バーを紹介します。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 で色を変えられることを確認してみてください。

    【関連記事】