スクロール進捗バー

ページの読み進み度を上部バーと円形リングで可視化します。長文記事やブログの定番UX。

#javascript#svg#ux

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW コラム。読み進み度を上部バー+円リングで表示 -->
<div class="mbp-scroller" id="progScroller">
  <!-- 上部の進捗バー(sticky) -->
  <div class="mbp-topbar">
    <div class="mbp-fill" id="progFill"></div>
  </div>

  <!-- 右下に貼り付く円形リング -->
  <div class="mbp-ring" aria-hidden="true">
    <svg viewBox="0 0 44 44" width="44" height="44">
      <circle cx="22" cy="22" r="19" fill="none" stroke="rgba(43,29,18,.12)" stroke-width="4"/>
      <circle id="progRingFg" cx="22" cy="22" r="19" fill="none" stroke="#c98a3b"
              stroke-width="4" stroke-linecap="round"
              stroke-dasharray="119.4" stroke-dashoffset="119.4"
              transform="rotate(-90 22 22)"/>
    </svg>
    <span class="mbp-pct" id="progPct">0%</span>
  </div>

  <article class="mbp-article">
    <p class="mbp-kicker">BREW GUIDE</p>
    <h1>おうちで淹れる、満月のドリップ</h1>
    <p class="mbp-meta">MOON BREW 編集部 ・ 読了 約3分</p>

    <p>こんばんは、MOON BREW です。今夜は、お店の定番「ムーンドリップ」をご家庭で再現するコツを、はじめての方にもわかるようにご紹介します。</p>

    <h2>1. 豆は飲む直前に挽く</h2>
    <p>コーヒーの香りは、挽いた瞬間からどんどん逃げていきます。可能であれば、飲む直前に中挽きで。粉が均一だと、お湯の通り道が安定して、雑味の少ない一杯になります。</p>

    <h2>2. お湯は少し落ち着かせる</h2>
    <p>沸騰したてのお湯は熱すぎることがあります。カップに一度移してから戻すなどして、90℃前後まで落ち着かせるのがおすすめ。浅煎りなら高め、深煎りなら低めが目安です。</p>

    <h2>3. 最初の30秒は「蒸らし」</h2>
    <p>粉全体がふわっと膨らむ程度のお湯を注ぎ、30秒ほど待ちます。ここでガスが抜け、続く抽出が驚くほど安定します。膨らみが弱いときは、豆が少し古いサインかもしれません。</p>

    <h2>4. 「の」の字でゆっくり</h2>
    <p>中心から外へ、また中心へ。ひらがなの「の」を書くように、細く静かに注ぎます。一気に注がず、数回に分けて。お湯がドリッパーの縁に触れないように気をつけて。</p>

    <h2>5. 落としきる前に外す</h2>
    <p>最後の数滴には雑味が出やすいので、お湯が落ちきる少し手前でドリッパーを外します。あとはカップを両手で包み、湯気ごと香りを楽しんでください。</p>

    <p class="mbp-end">— よい夜を。MOON BREW —</p>
  </article>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
}

body {
  font-family: "Hiragino Mincho ProN", "Yu Mincho", "Segoe UI", serif;
  background: var(--cream);
  color: var(--brown);
  -webkit-font-smoothing: antialiased;
}

/* 内部スクロール領域 */
.mbp-scroller {
  position: relative;
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: var(--amber) transparent;
}

/* 上部の進捗バー(貼り付く) */
.mbp-topbar {
  position: sticky;
  top: 0;
  z-index: 6;
  height: 5px;
  background: rgba(43,29,18,.08);
}
.mbp-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #b9772b, var(--amber));
  box-shadow: 0 0 10px rgba(201,138,59,.5);
  transition: width .08s linear;
}

/* 右下の円リング(貼り付く) */
.mbp-ring {
  position: sticky;
  top: calc(100vh - 70px);
  float: right;
  margin: 0 16px -44px 0;
  z-index: 6;
  width: 44px; height: 44px;
}
.mbp-pct {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: "Segoe UI", sans-serif;
  font-size: .56rem;
  font-weight: 700;
  color: #9a652a;
}
#progRingFg { transition: stroke-dashoffset .1s linear; }

/* 記事本文 */
.mbp-article {
  max-width: 560px;
  margin: 0 auto;
  padding: 34px 26px 110px;
}
.mbp-kicker { font-family: "Segoe UI", sans-serif; letter-spacing: .3em; font-size: .62rem; color: var(--amber); }
.mbp-article h1 { font-size: 1.8rem; font-weight: 700; line-height: 1.4; margin: 10px 0 8px; }
.mbp-meta { font-family: "Segoe UI", sans-serif; font-size: .72rem; color: #9c8a72; margin-bottom: 24px; }
.mbp-article h2 { font-size: 1.1rem; font-weight: 700; margin: 24px 0 8px; color: #6b4a23; }
.mbp-article p { font-size: .92rem; line-height: 1.95; color: #4d3a28; margin-bottom: 14px; }
.mbp-end {
  text-align: center;
  letter-spacing: .14em;
  color: var(--amber);
  margin-top: 28px;
}
JavaScript
// MOON BREW コラム:読み進み度を上部バー+円リングに反映
(() => {
  const scroller = document.getElementById('progScroller');
  const fill = document.getElementById('progFill');
  const ringFg = document.getElementById('progRingFg');
  const pct = document.getElementById('progPct');
  if (!scroller) return; // null安全

  const CIRC = 119.4; // リング円周 (2π * 19)

  // 進捗を計算して反映
  function update() {
    const scrollable = scroller.scrollHeight - scroller.clientHeight;
    // 0除算ガード:スクロール不能なら0%扱い
    const ratio = scrollable > 0 ? Math.min(scroller.scrollTop / scrollable, 1) : 0;
    const p = Math.round(ratio * 100);

    if (fill) fill.style.width = p + '%';
    if (ringFg) ringFg.style.strokeDashoffset = String(CIRC * (1 - ratio));
    if (pct) pct.textContent = p + '%';
  }

  // rAFでスクロールイベントを間引く
  let ticking = false;
  scroller.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { update(); ticking = false; });
  }, { passive: true });

  update(); // 初期表示

  // 操作がなくても進捗が見えるよう、一度だけゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.5;
      requestAnimationFrame(step);
    }, 700);
  }
})();

コード

HTML
<!-- 自前のスクロール領域(プレビュー枠内で完結) -->
<div class="prog-scroller" id="progScroller">
  <!-- 上部固定の読み進み度バー -->
  <div class="prog-bar" aria-hidden="true">
    <span class="prog-fill" id="progFill"></span>
  </div>

  <!-- 円形パーセンテージ(右下固定) -->
  <div class="prog-ring" id="progRing">
    <svg viewBox="0 0 44 44">
      <circle class="prog-ring-bg" cx="22" cy="22" r="19"></circle>
      <circle class="prog-ring-fg" id="progRingFg" cx="22" cy="22" r="19"></circle>
    </svg>
    <span class="prog-pct" id="progPct">0%</span>
  </div>

  <article class="prog-article">
    <h1>記事の読み進み度</h1>
    <p class="prog-lead">スクロール量を 0〜100% に正規化し、上部バーと円リングへ同時反映します。長文記事やブログで定番の体験。</p>
    <p>スクロールに合わせて、トップのグラデーションバーが伸び、右下のリングが満ちていきます。requestAnimationFrameでスクロールイベントを間引き、再描画を最適化しています。</p>
    <p>進捗の計算式はシンプル。scrollTop ÷ (全体の高さ − ビューポート高) で割合が出ます。0除算を避けるためのガードも入れています。</p>
    <p>パーセンテージはJSから直接スタイルへ反映するため、見た目の調整はCSS側だけで完結。色やバーの高さを変えるのも簡単です。</p>
    <p>このまま下までスクロールすると、リングがちょうど100%で満タンになります。ヘッダーやフッターにそのまま組み込めます。</p>
    <p>読了率の可視化は、ユーザーに「あとどれくらい」を伝えUXを高めます。装飾要素なので aria-hidden を付与。</p>
    <p>最後まで来ました。スクロール演出のなかでも実装コストが低く、効果が高い王道テクニックです。</p>
  </article>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --accent: #ff4d6d;
  --accent2: #ffb000;
  --bg: #fbfaf7;
  --ink: #1c1c22;
}

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  background: var(--bg);
  color: var(--ink);
  line-height: 1.7;
}

/* プレビュー枠を埋める自前スクロール領域 */
.prog-scroller {
  position: relative;
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

/* 上部固定バー(スクロール領域の上端に貼り付く) */
.prog-bar {
  position: sticky;
  top: 0; left: 0; right: 0;
  height: 6px;
  background: rgba(0,0,0,.06);
  z-index: 20;
}
.prog-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  border-radius: 0 3px 3px 0;
}

/* 円形リング */
.prog-ring {
  position: fixed;
  right: 16px; bottom: 16px;
  width: 56px; height: 56px;
  z-index: 20;
  filter: drop-shadow(0 4px 10px rgba(0,0,0,.12));
}
.prog-ring svg {
  width: 100%; height: 100%;
  transform: rotate(-90deg);
}
.prog-ring circle {
  fill: none;
  stroke-width: 4;
  stroke-linecap: round;
}
.prog-ring-bg { stroke: rgba(0,0,0,.08); }
.prog-ring-fg {
  stroke: var(--accent);
  stroke-dasharray: 119.4; /* 2πr (r=19) */
  stroke-dashoffset: 119.4;
}
.prog-pct {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: .72rem;
  font-weight: 700;
  color: var(--accent);
  background: #fff;
  border-radius: 50%;
  margin: 6px;
}

/* 本文 */
.prog-article {
  max-width: 620px;
  margin: 0 auto;
  padding: 36px 26px 90px;
}
.prog-article h1 {
  font-size: 2rem;
  font-weight: 800;
  margin-bottom: 14px;
}
.prog-lead {
  font-size: 1.05rem;
  color: #5a5a66;
  padding: 14px 18px;
  border-left: 4px solid var(--accent);
  background: rgba(255,77,109,.06);
  border-radius: 0 10px 10px 0;
  margin-bottom: 22px;
}
.prog-article p { margin-bottom: 18px; }
JavaScript
// 自前スクロール領域の進捗を上部バー+円リングに反映
(() => {
  const scroller = document.getElementById('progScroller');
  const fill = document.getElementById('progFill');
  const ringFg = document.getElementById('progRingFg');
  const pct = document.getElementById('progPct');
  if (!scroller) return; // null安全

  const CIRC = 119.4; // リング円周 (2π * 19)

  // 進捗を計算して反映
  function update() {
    const scrollable = scroller.scrollHeight - scroller.clientHeight;
    // 0除算ガード:スクロール不能なら0%扱い
    const ratio = scrollable > 0 ? Math.min(scroller.scrollTop / scrollable, 1) : 0;
    const p = Math.round(ratio * 100);

    if (fill) fill.style.width = p + '%';
    if (ringFg) ringFg.style.strokeDashoffset = String(CIRC * (1 - ratio));
    if (pct) pct.textContent = p + '%';
  }

  // rAFでスクロールイベントを間引く
  let ticking = false;
  scroller.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { update(); ticking = false; });
  }, { passive: true });

  update(); // 初期表示

  // 操作がなくても進捗が見えるよう、一度だけゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.5;
      requestAnimationFrame(step);
    }, 700);
  }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「スクロール進捗バー」の効果を追加してください。

# 追加してほしい効果
スクロール進捗バー(スクロール演出)
ページの読み進み度を上部バーと円形リングで可視化します。長文記事やブログの定番UX。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 自前のスクロール領域(プレビュー枠内で完結) -->
<div class="prog-scroller" id="progScroller">
  <!-- 上部固定の読み進み度バー -->
  <div class="prog-bar" aria-hidden="true">
    <span class="prog-fill" id="progFill"></span>
  </div>

  <!-- 円形パーセンテージ(右下固定) -->
  <div class="prog-ring" id="progRing">
    <svg viewBox="0 0 44 44">
      <circle class="prog-ring-bg" cx="22" cy="22" r="19"></circle>
      <circle class="prog-ring-fg" id="progRingFg" cx="22" cy="22" r="19"></circle>
    </svg>
    <span class="prog-pct" id="progPct">0%</span>
  </div>

  <article class="prog-article">
    <h1>記事の読み進み度</h1>
    <p class="prog-lead">スクロール量を 0〜100% に正規化し、上部バーと円リングへ同時反映します。長文記事やブログで定番の体験。</p>
    <p>スクロールに合わせて、トップのグラデーションバーが伸び、右下のリングが満ちていきます。requestAnimationFrameでスクロールイベントを間引き、再描画を最適化しています。</p>
    <p>進捗の計算式はシンプル。scrollTop ÷ (全体の高さ − ビューポート高) で割合が出ます。0除算を避けるためのガードも入れています。</p>
    <p>パーセンテージはJSから直接スタイルへ反映するため、見た目の調整はCSS側だけで完結。色やバーの高さを変えるのも簡単です。</p>
    <p>このまま下までスクロールすると、リングがちょうど100%で満タンになります。ヘッダーやフッターにそのまま組み込めます。</p>
    <p>読了率の可視化は、ユーザーに「あとどれくらい」を伝えUXを高めます。装飾要素なので aria-hidden を付与。</p>
    <p>最後まで来ました。スクロール演出のなかでも実装コストが低く、効果が高い王道テクニックです。</p>
  </article>
</div>

【CSS】
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --accent: #ff4d6d;
  --accent2: #ffb000;
  --bg: #fbfaf7;
  --ink: #1c1c22;
}

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  background: var(--bg);
  color: var(--ink);
  line-height: 1.7;
}

/* プレビュー枠を埋める自前スクロール領域 */
.prog-scroller {
  position: relative;
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

/* 上部固定バー(スクロール領域の上端に貼り付く) */
.prog-bar {
  position: sticky;
  top: 0; left: 0; right: 0;
  height: 6px;
  background: rgba(0,0,0,.06);
  z-index: 20;
}
.prog-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  border-radius: 0 3px 3px 0;
}

/* 円形リング */
.prog-ring {
  position: fixed;
  right: 16px; bottom: 16px;
  width: 56px; height: 56px;
  z-index: 20;
  filter: drop-shadow(0 4px 10px rgba(0,0,0,.12));
}
.prog-ring svg {
  width: 100%; height: 100%;
  transform: rotate(-90deg);
}
.prog-ring circle {
  fill: none;
  stroke-width: 4;
  stroke-linecap: round;
}
.prog-ring-bg { stroke: rgba(0,0,0,.08); }
.prog-ring-fg {
  stroke: var(--accent);
  stroke-dasharray: 119.4; /* 2πr (r=19) */
  stroke-dashoffset: 119.4;
}
.prog-pct {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: .72rem;
  font-weight: 700;
  color: var(--accent);
  background: #fff;
  border-radius: 50%;
  margin: 6px;
}

/* 本文 */
.prog-article {
  max-width: 620px;
  margin: 0 auto;
  padding: 36px 26px 90px;
}
.prog-article h1 {
  font-size: 2rem;
  font-weight: 800;
  margin-bottom: 14px;
}
.prog-lead {
  font-size: 1.05rem;
  color: #5a5a66;
  padding: 14px 18px;
  border-left: 4px solid var(--accent);
  background: rgba(255,77,109,.06);
  border-radius: 0 10px 10px 0;
  margin-bottom: 22px;
}
.prog-article p { margin-bottom: 18px; }

【JavaScript】
// 自前スクロール領域の進捗を上部バー+円リングに反映
(() => {
  const scroller = document.getElementById('progScroller');
  const fill = document.getElementById('progFill');
  const ringFg = document.getElementById('progRingFg');
  const pct = document.getElementById('progPct');
  if (!scroller) return; // null安全

  const CIRC = 119.4; // リング円周 (2π * 19)

  // 進捗を計算して反映
  function update() {
    const scrollable = scroller.scrollHeight - scroller.clientHeight;
    // 0除算ガード:スクロール不能なら0%扱い
    const ratio = scrollable > 0 ? Math.min(scroller.scrollTop / scrollable, 1) : 0;
    const p = Math.round(ratio * 100);

    if (fill) fill.style.width = p + '%';
    if (ringFg) ringFg.style.strokeDashoffset = String(CIRC * (1 - ratio));
    if (pct) pct.textContent = p + '%';
  }

  // rAFでスクロールイベントを間引く
  let ticking = false;
  scroller.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { update(); ticking = false; });
  }, { passive: true });

  update(); // 初期表示

  // 操作がなくても進捗が見えるよう、一度だけゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.5;
      requestAnimationFrame(step);
    }, 700);
  }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。