Reactで、記事をどこまで読んだかを示すバーを出す方法

​​React​CSS,Playwright,ResizeObserver,TypeScript,アクセシビリティ,カスタムフック

記事をどこまで読んだかを示すバーは、記事の要素の位置を getBoundingClientRect() で測り、「記事の先頭が画面の上端に来たら 0%、記事の末尾が画面の下端に来たら 100%」として割合を出すと作れます。ページ全体のスクロール量から計算すると、記事の下にコメント欄やフッターがあるページでは、記事を読み終えてもバーが 100% になりません。

ここでは、割合を返すカスタムフック useReadingProgress と、バーのコンポーネントを作りました。あわせて、画像があとから読み込まれて記事の高さが変わったときにバーがずれないかと、CSS のスクロール駆動アニメーションだけで同じバーを作った場合の動きも確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、スクロールの位置ごとにバーの値を読み取りました。Firefox と Safari、実機のスマートフォンでは試していません。

ホイールで下へスクロールすると上のバーが伸び、記事の末尾で 100% になり、コメント欄へ進んでも 100% のまま、いちばん上へ戻すと 0% になる
下へ3回、上へ1回スクロールしたときのバーと aria-valuenow の値。値が変わっているコマだけ数字をピンクにしている(カーソル、ホイールの表示、操作の回数と値の表示は GIF 用に重ねたもの)

記事の要素の位置から、読んだ割合を出すフック

getBoundingClientRect() が返す top は、画面(ビューポート)の左上から測った位置です。記事を下へ読み進めると、記事の先頭が画面の上へ出ていき、top はマイナスになっていきます。そこで、-top を「記事のうち画面に収まりきらない分の高さ」(記事の高さから画面の高さを引いたもの)で割り、0〜1 の範囲に収めます。

ts
import { useEffect, useState, type RefObject } from 'react';

// 記事の要素をどこまで読んだかを 0〜1 で返す。
// 記事の先頭が画面の上端に来たら 0、記事の末尾が画面の下端に来たら 1
export function useReadingProgress(ref: RefObject<HTMLElement | null>) {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const article = ref.current;
    if (!article) return;

    const update = () => {
      const rect = article.getBoundingClientRect();
      // 記事のうち、画面に収まりきらない分の高さ
      const scrollable = rect.height - window.innerHeight;
      // 記事が画面に収まるときは、読み終えた扱いにする
      const value = scrollable <= 0 ? 1 : -rect.top / scrollable;
      setProgress(Math.min(1, Math.max(0, value)));
    };

    update();
    window.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    // 画像の読み込みなどで記事の高さが変わったときも計算し直す
    const observer = new ResizeObserver(update);
    observer.observe(article);

    return () => {
      window.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
      observer.disconnect();
    };
  }, [ref]);

  return progress;
}

window のイベントは JSX では受けられないので、useEffect の中で addEventListener し、クリーンアップで外します。公式リファレンスの useEffect で window のイベントを受ける例と同じ形です。scroll のリスナーには passive: true を付けました。MDN の addEventListener の説明によると、これは preventDefault() を呼ばないことをブラウザに伝えるオプションです。ただし同じ節には、scroll イベントでは passive の値を気にしなくてよいとも書かれています。

バーのほうは、外側の枠を画面の上に固定し、中の帯を transform: scaleX() で伸ばします。transform-origin を左端にしておくと、左から右へ伸びます。

tsx
export function ReadingProgressBar({ value }: { value: number }) {
  return (
    <div
      className="progress"
      role="progressbar"
      aria-label="記事を読んだ割合"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(value * 100)}
    >
      <div className="progress-fill" style={{ transform: `scaleX(${value})` }} />
    </div>
  );
}
css
.progress { position: fixed; top: 0; left: 0; right: 0; height: 6px; background: #eefafd; z-index: 10; }
.progress-fill { height: 100%; background: #00c8e8; transform-origin: 0 50%; }

使う側では、記事の要素に ref を付けてフックに渡し、返ってきた値をバーに渡します。

tsx
const articleRef = useRef<HTMLElement>(null);
const progress = useReadingProgress(articleRef);

return (
  <>
    <ReadingProgressBar value={progress} />
    <header className="site-header">サイトのヘッダー</header>
    <article ref={articleRef} className="article">
      {/* 本文 */}
    </article>
    <section className="comments">コメント欄</section>
  </>
);

ページ全体の割合だと、読み終えても 100% にならない

検証に使ったページは、高さ 60px のヘッダー、記事(高さ 2700px)、高さ 600px のコメント欄、高さ 100px のフッターを縦に並べたものです。画面の高さを 800px にして、スクロールの位置ごとにバーの値を読み取りました。「バー」は帯に実際にかかっている scaleX の値、「ページ全体の割合」は、比べるために scrollY をページのスクロールできる量で割ったものです。

text
いちばん上: scrollY=0 記事の高さ=2700 バー=0.000 aria-valuenow=0 ページ全体の割合=0.000
記事の先頭が画面の上端: scrollY=60 記事の高さ=2700 バー=0.000 aria-valuenow=0 ページ全体の割合=0.023
記事の真ん中: scrollY=1010 記事の高さ=2700 バー=0.500 aria-valuenow=50 ページ全体の割合=0.380
記事の末尾が画面の下端: scrollY=1960 記事の高さ=2700 バー=1.000 aria-valuenow=100 ページ全体の割合=0.737
ページのいちばん下: scrollY=2660 記事の高さ=2700 バー=1.000 aria-valuenow=100 ページ全体の割合=1.000

記事の末尾が画面の下端に来たところで、バーは 100% になりました。ページ全体で計算すると、この位置では 73.7% です。コメント欄とフッターの分だけ、読み終えたのにバーが残ってしまいます。また、ヘッダーが見えている間は、記事を読み始めていないのでバーは 0% のままです。

画像があとから読み込まれると、scroll イベントだけではずれる

記事の中の画像に width と height を書いていないと、画像が届くまで高さが 0 で、届いたところで記事が長くなります。このとき、スクロールはしていないので scroll イベントは起きません。

これを確かめるため、記事の途中にある画像(届くと記事が 640px 伸びる)を、サーバーで3秒遅らせて返しました。画像が届く前の記事の高さで、バーがちょうど 50% になる位置(画像より上)までスクロールしておき、画像が届く前とあとでバーの値を比べています。まず、scroll イベントだけで計算し直す書き方(上のフックから resize と ResizeObserver を除いたもの)です。

text
画像が届く前: scrollY=690 記事の高さ=2060 バー=0.500 aria-valuenow=50 ページ全体の割合=0.342
画像が届いたあと(スクロールしていない): scrollY=690 記事の高さ=2700 バー=0.500 aria-valuenow=50 ページ全体の割合=0.259
ホイールで 1px スクロールしたあと: scrollY=691 記事の高さ=2700 バー=0.332 aria-valuenow=33 ページ全体の割合=0.260

記事が 2060px から 2700px に伸びたのに、バーは 50% のままでした。ホイールで 1px スクロールしたところで、ようやく 33% に変わっています。

ResizeObserver で記事の要素を見張っている useReadingProgress では、画像が届いた時点で値が変わりました。

text
画像が届く前: scrollY=690 記事の高さ=2060 バー=0.500 aria-valuenow=50 ページ全体の割合=0.342
画像が届いたあと(スクロールしていない): scrollY=690 記事の高さ=2700 バー=0.332 aria-valuenow=33 ページ全体の割合=0.259
ホイールで 1px スクロールしたあと: scrollY=691 記事の高さ=2700 バー=0.332 aria-valuenow=33 ページ全体の割合=0.260

ResizeObserver は、要素の大きさが変わったことを知らせてくれる仕組みです。画像に限らず、スクロールしていない間に記事の高さが変わると、スクロールのときだけ測る書き方では、その変化を拾えません。

ポイント

読んだ割合は「記事の要素の位置」から計算し、計算し直すきっかけには scroll と resize に加えて ResizeObserver を使います。これで、記事の下に別の内容があるページでも、記事の高さがあとから変わるページでも、バーが記事の読み具合と合います。

スクリーンリーダーに割合を伝える

バーの外側の要素には role="progressbar" を付け、aria-valuenow に 0〜100 の値を入れています。MDN の progressbar ロールのページには、aria-valuenow には今の値を入れて更新し続けること、名前(accessible name)は必ず付けることが書かれています。画面に見えるラベルがないときは aria-label を使います。

記事の真ん中までスクロールしたところで、Chromium のアクセシビリティツリーを読み出すと、次のようになっていました。

text
role=progressbar name=記事を読んだ割合 value=50 valuemin=0 valuemax=100

同じページの Prefer HTML の節では、できれば HTML の <progress> 要素を使うよう勧めています。ここでは帯を transform で伸ばすために div に役割を付けましたが、見た目の作り方に強いこだわりがなければ、<progress> を使う手もあります。<progress> での見た目や読み上げは、この記事では試していません。

CSS のスクロール駆動アニメーションだけで作る

CSS のスクロール駆動アニメーションを使うと、JavaScript で割合を計算せずに、同じバーを作れます。記事の要素に view-timeline-name で名前を付け、バーの帯の animation-timeline にその名前を指定します。名前を付けたタイムラインは、既定ではその要素の子孫からしか指定できないので(MDN の timeline-scope の説明)、バーは記事の要素の中に置きました。

tsx
<article className="article article-timeline">
  <div className="progress" aria-hidden="true">
    <div className="progress-fill progress-fill-css" />
  </div>
  {/* 本文 */}
</article>
css
.article-timeline { view-timeline-name: --article; }
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.progress-fill-css {
  animation: grow linear both;
  animation-timeline: --article;
  animation-range: contain;
}

animation-range の contain は、要素が画面より大きいとき、要素が画面をすっかり覆い始めたところから、覆わなくなるところまでの範囲です(MDN のタイムラインの範囲名の説明)。記事を下へ読んでいく場合、記事の先頭が画面の上端に来たところから、記事の末尾が画面の下端に来たところまでになり、フックの計算と同じ範囲です。なお、animation-timeline は animation の一括指定で auto に戻されるので、animation より後に書きます。

フックのときと同じページと位置で、帯に実際にかかっている scaleX を読み取りました。

text
いちばん上: scrollY=0 記事の高さ=2700 バー=0.000 ページ全体の割合=0.000
記事の先頭が画面の上端: scrollY=60 記事の高さ=2700 バー=0.000 ページ全体の割合=0.023
記事の真ん中: scrollY=1010 記事の高さ=2700 バー=0.500 ページ全体の割合=0.380
記事の末尾が画面の下端: scrollY=1960 記事の高さ=2700 バー=1.000 ページ全体の割合=0.737
ページのいちばん下: scrollY=2660 記事の高さ=2700 バー=1.000 ページ全体の割合=1.000

値はフックと同じでした。画像を3秒遅らせた場合も、画像が届いた時点で 0.332 に変わり、スクロールを待たずに追従しました。

text
画像が届く前: scrollY=690 記事の高さ=2060 バー=0.500 ページ全体の割合=0.342
画像が届いたあと(スクロールしていない): scrollY=690 記事の高さ=2700 バー=0.332 ページ全体の割合=0.259

ただし、CSS だけで伸ばす場合、割合は JavaScript の値として手元にないので、aria-valuenow を入れられません。このバーには aria-hidden="true" を付け、見た目だけのものにしています。割合をスクリーンリーダーに伝えたいときや、「残り何分」の表示のように割合を別の場所で使いたいときは、フックで計算する書き方が向いています。

MDN の互換性データでは、animation-timeline は Chrome 115、Firefox 159、Safari 26 から使えるとされています(Firefox 159 は、同じデータではリリース日が 2026年10月27日、状態がベータ版になっています)。この記事で試したのは Chromium 153 だけです。animation-timeline に対応していないバージョンのブラウザにも対応したいときは、フックの書き方が使えます。

​Posted by 86