12k
All articles

読書プログレスバーの作り方

JavaScriptまたはCSSのスクロール連動アニメーションで読書進捗バーを作成し、正しいスクロール計算、性能、アクセシビリティまで解説します。

OpenReplay Team
OpenReplay Team
読書プログレスバーの作り方

読書プログレスバー(reading progress bar)とは、細長い固定表示のインジケーター(通常はビューポート上端に固定される)で、読者が長い記事をスクロールするにつれて 0% から 100% まで満たされていくものです。

私が最初にリリースしたバージョンは、記事本文と一緒にコメント欄とフッターまで黙って計測していたため、記事末尾の3画面ほど手前で 100% に到達してしまいました。この1点を正しく扱うことが、実は作業の大半を占めます。

実装方法は2つあります。スクロール割合の計算からバーの幅を設定する JavaScript のスクロールリスナーを使う方法と、JavaScript を一切使わない純 CSS のスクロール駆動アニメーションを使う方法です。本ガイドでは両方の実装、ドキュメント全体スコープと記事スコープのそれぞれで正しいスクロール計算、スクロールハンドラーを軽量に保つためのパフォーマンス上の注意点、そしてリリース前に押さえておくべきアクセシビリティとプログレッシブエンハンスメントの扱いを解説します。

要点

  • ドキュメント全体を対象とするバーの場合、スクロール進捗は scrollTop / (scrollHeight − clientHeight) × 100 です。記事のみを追跡するバーの場合は <article> を計測します: window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100
  • 関連記事ブロック、コメント欄、背の高いフッターがあるページでは記事スコープの式を使いましょう。そうすることで、バーはページ末尾ではなく記事末尾で 100% に到達します。
  • scroll はほぼ毎フレーム発火するため、幅の更新は requestAnimationFrame 内で実行し、高さの読み取り結果はキャッシュして resize 時のみ再計算します。これによりハンドラーが同期的なレイアウトを強制することがなくなります。
  • CSS のみのバージョンには JavaScript が不要です。固定配置のバーに animation-timeline: scroll() を与え、transformscaleX(0) から scaleX(1) へアニメーションさせる @keyframes を用意し、animation-duration: 1ms を指定します。この duration は、フラグを有効にした環境や Nightly で Firefox がアニメーションを適用するために必要です。
  • スクロール駆動アニメーションは Chrome/Edge 115+、Safari 26+、Opera で利用できますが、安定版 Firefox が依然フラグの背後に隠しているため、まだ Baseline ではありません。CSS のみのバーはプログレッシブエンハンスメントとして扱いましょう。

読書プログレスバーとは何か、どんなときに使うべきか

読書プログレスバーは「この記事があとどれくらい残っているか」を、画面上端で伸びていくバーとして視覚的に表現します。長文コンテンツ(踏み込んだチュートリアル、エッセイ、ドキュメント)に適しており、現代の細いスクロールバーでは得られなくなった位置感覚を読者に提供します。短いページやランディングページ、1〜2画面で収まるようなコンテンツでは、誰にも情報を与えないまま視覚的ノイズを増やすだけなので、使わないでおきましょう。

以降の実装は、2つの設計判断によって決まります。バーがどの領域を計測するか(ドキュメント全体か記事本文のみか)、そして JavaScript で実装するか CSS で実装するかです。

読書進捗はどう計算するのか

計算を正しくすれば、あとは自然についてきます。バーに何を表現させたいかによって、正しい式は2種類あります。

ドキュメント全体のスクロール。 ページ全体のスクロールに応じて満たされるバーの場合、進捗はスクロールした距離をスクロール可能な最大距離で割った値になります:

progress = scrollTop / (scrollHeight − clientHeight) × 100

分母から可視領域の高さを引いているのは、最後のビューポート1画面分のコンテンツを視界外までスクロールすることは絶対にできないためです。つまり、1画面分がまだ見えている状態でページ末尾に到達します。ルートスクローラーでは、scrollHeight がコンテンツの総高さで、clientHeight が可視領域の高さです。

記事スコープのスクロール。 ドキュメント全体を対象とするバーは、フッター、コメント欄、関連記事ブロックまで数えてしまうため、記事の末尾ではなくページの末尾で 100% に達します。これを修正するには、代わりに <article> 要素を計測します:

distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100

ここでの distance は、最初の描画時点から記事の下端が視界に入る瞬間までのスクロール軌跡です。記事の下に相当量のコンテンツがあるページでは記事スコープの式を、スクロール可能なコンテンツそのものがページ全体である場合はドキュメントの式を使いましょう。なお offsetTop は最も近い位置指定された祖先要素を基準に計測されるため、この値を「ページ上端からの距離」として意味づけるには、記事要素を通常のドキュメントフローに置いたままにしてください。

JavaScript による実装

JavaScript によるアプローチはあらゆるブラウザーで動作し、記事スコープの正確な進捗を得られる唯一の方法です。固定配置のバー要素、少量の CSS、そしてスクロールハンドラーが必要です。

<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 4px;
  background: linear-gradient(to right, #7b2ff7, #f107a3);
  z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;

function measure() {
  distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}

function update() {
  const progress = Math.min((window.scrollY / distance) * 100, 100);
  bar.style.width = `${progress}%`;
  ticking = false;
}

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(update);
    ticking = true;
  }
}

window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);

計測は load ハンドラー内で実行しているため、画像やフォントの読み込みが落ち着いた状態で clientHeight が正確になります。ページ全体を対象とするバーが必要な場合は、記事スコープの distance をドキュメントの式に差し替えてください。

スクロールハンドラーを高速に保つ

scroll イベントはほぼ毎アニメーションフレームで発火しうるため、イベントごとにレイアウトを読み取ってスタイルを書き込むような素朴なハンドラーは、確実にジャンクの原因になります。軽量に保つためのルールは2つです。

1つ目は、上記の ticking フラグを使って視覚的な書き込みを requestAnimationFrame にまとめることです。これにより、scroll がどれだけ頻繁に発火しても、バーの更新は1フレームあたり最大1回に抑えられます。2つ目は、高さの読み取り結果をキャッシュすることです。スクロールイベントごとに clientHeight/offsetTop を読み取ると、ブラウザーは保留中のレイアウトをフラッシュせざるを得ず、この繰り返されるリフローが実際のレイアウトスラッシングの姿です。したがって distance は一度だけ計算し、再計算は resize 時のみに限定します。本番環境でよくある破綻パターンはまさにこれで、スロットリングされていないリスナーがイベントごとにジオメトリを読み取り width を書き込むというもので、スクロールの多いページのセッションリプレイでは、その結果として生じるフレーム落ちが頻繁に浮かび上がります。リスナーを { passive: true } で登録すると、preventDefault を呼ばないことをブラウザーに伝えられるため、スクロールもスムーズに保たれます。

CSS のみの読書プログレスバー

CSS スクロール駆動アニメーションを使えば、JavaScript ゼロでバーを構築できます。アニメーションを経過時間ではなくスクロールタイムラインに結びつけると、ブラウザーがスクロール位置からバーの水平方向のスケールを駆動します。このアニメーションはレイアウト系プロパティではなく transform を対象とするため、メインスレッド上のスクロールリスナーを経由せず、コンポジターで実行できます。

<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    #reading-progress {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      z-index: 9999;
      background: #7b2ff7;
      transform: scaleX(0);
      transform-origin: left;
      animation-name: grow-progress;
      animation-timeline: scroll();
      animation-duration: 1ms; /* required so the animation runs in Firefox */
      animation-timing-function: linear;
    }
    @keyframes grow-progress {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
    @media (prefers-color-scheme: dark) {
      #reading-progress { background: #fc0; }
    }
  }
}

バーは全幅でレイアウトされたうえで transform: scaleX(0) によって潰され、スクロールに応じて元の大きさへ拡大されていきます。中央からではなく左端から伸びるようにしているのが transform-origin: left です。代わりに width をアニメーションさせても見た目は同一ですが、毎フレームでレイアウトが強制され、アニメーションがメインスレッドに引き戻されてしまいます。

さらに2点、重要な詳細があります。引数なしで呼び出された scroll() は、スクロールする最も近い祖先要素を選び、そのブロック軸に従います。多くの単一カラムの記事レイアウトではこれはルートスクローラーを意味します。明示的に指定したい場合は root を渡してください。また、Firefox は animation-duration が 0 でない場合を除きアニメーションを適用しないため、慣例的な 1ms がそこで動作させる決め手になります。そして同じ値が、サポートのないブラウザーではバーを非表示のまま保つ役割も果たします。

最後の点はトレードオフです。animation-timeline は Baseline ではありません。Chrome と Edge 115+、Safari 26+、Opera で利用できますが、安定版 Firefox は依然として layout.css.scroll-driven-animations.enabled フラグの背後に置いており、デフォルトで有効なのは Nightly のみです。上記の @supports ガードはプログレッシブエンハンスメントの契約です。対応ブラウザーは CSS のバーを得て、それ以外では何も描画されません。したがって、あらゆる環境をカバーする必要があるなら JavaScript 版をフォールバックとして併用してください。なお、CSS のみのバーはスクロールコンテナー全体を計測するため、ドキュメントスコープの JS の式と同様に、フッターやコメントのコンテンツも数えてしまいます。

JavaScript か CSS のみか: どちらを使うべきか

JavaScript のバーCSS のみのバー
ブラウザーサポートすべての環境Chromium 115+、Safari 26+;Firefox はフラグの背後
記事スコープの正確さ可能不可、ページ全体を数える
メインスレッドのコストスクロールリスナーフレームごとのコストなし、transform はコンポジターで実行
JavaScript の必要性必要不要

バーを記事末尾で止めたい場合、またはあらゆるブラウザーをサポートしなければならない場合は JavaScript を使いましょう。最小限のコードでページ全体のインジケーターが欲しく、それをエンハンスメントとして扱えるなら CSS のみのバーを使いましょう。

アクセシビリティと仕上げ

プログレスバーは装飾的なクロームなので、aria-hidden="true" を付けてアクセシビリティツリーから外し、スクリーンリーダーの出力やフォーカス順序から遠ざけましょう。本当に値を読み上げさせたい場合は、代わりに role="progressbar" とライブな aria-valuenow を使いますが、ほとんどの読書インジケーターでは非表示にするのが正解です。CSS アニメーションは @media (prefers-reduced-motion: no-preference) で囲み、モーションを避ける設定のユーザーにアニメーション要素が表示されないようにしてください。また、ライトテーマとダークテーマの両方で視認できるよう、ヘッダーに対して十分なコントラストを持つバーの色を選びましょう。

どちらのアプローチも、見た目の結果は同じです。JavaScript 版は記事スコープの正確さと全ブラウザー対応をもたらし、CSS のみの版はより小さな実装でフレームごとの処理をメインスレッドから外し続けてくれます。自分のブラウザーターゲットに合う方から始め、スクロールの計算と animation-duration: 1ms の細部は示したとおりそのまま維持し、両方の利点を得たい場合は @supports で重ねて使いましょう。

FAQ

記事を読み終える前にプログレスバーが100パーセントに達してしまうのはなぜですか?

バーが記事ではなくドキュメント全体を計測しており、スクロール可能な距離にフッター、コメント欄、関連記事ブロックまで数えてしまっているためです。記事スコープの式に切り替えてください。distance を (article.clientHeight + article.offsetTop) から window.innerHeight を引いた値として計算し、window.scrollY をその distance で割ります。これでバーはページ末尾ではなく記事末尾で100パーセントに到達します。

CSSのみのプログレスバーがChromeでは動作するのにFirefoxでは動作しないのはなぜですか?

Firefox は安定版リリースにおいてスクロール駆動アニメーションを layout.css.scroll-driven-animations.enabled フラグの背後に置いており、この設定がデフォルトで有効なのは Nightly のみです。そのためフラグを有効にしていない Firefox では何も描画されません。加えて Firefox は animation-duration が 0 でない場合を除きアニメーションをまったく適用しないため、誰もが 1ms という値を使うのです。全環境をカバーするには、CSS のバーを at-supports のガードと JavaScript のフォールバックと組み合わせてください。

CSSのみのバーはスクロールイベントリスナーなしで動作しますか?

はい。CSS スクロール駆動アニメーションはアニメーションを経過時間ではなくスクロールタイムラインに結びつけるため、ブラウザーはスクロール位置から直接バーの transform を駆動し、JavaScript のスクロールリスナーもメインスレッド上の IntersectionObserver も不要です。width ではなく transform をアニメーションさせることがコンポジターにやさしい実装の鍵です。Chromium および Safari 26.4 以降ではアニメーションがコンポジタースレッドで実行されますが、それ以前の Safari 26.x ではスクロール駆動アニメーションがメインスレッドで実行されていました。width や height をアニメーションさせると毎フレームでレイアウトが強制され、どのブラウザーでも処理がメインスレッドに戻ってしまいます。

読書プログレスバーはスクリーンリーダーに公開すべきですか?

ほとんどの読書インジケーターでは、公開すべきではありません。プログレスバーは装飾的なクロームなので、aria-hidden='true' を付けてアクセシビリティツリーから外し、スクリーンリーダーの出力やフォーカス順序から遠ざけてください。本当に値を読み上げる必要がある場合にのみ、代わりに role='progressbar' とライブな aria-valuenow 属性を使うべきですが、純粋に視覚的な読書インジケーターは非表示にするのが正しいデフォルトです。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.