スクロール進捗バー
ページの読み進み度を上部バーと円形リングで可視化します。長文記事やブログの定番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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。