戻る↑ボタン(進捗リング付き)

一定量スクロールすると現れる「トップへ戻る」ボタン。周囲のリングが読み進み度を示し、押すと最上部へ滑らかに戻ります。長いページの定番ユーティリティをひとつにまとめました。

#sticky#back-to-top#progress#scroll

ライブデモ

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

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

HTML
<!-- MOON BREW:焙煎日記の読了補助(戻る↑+進捗リング) -->
<div class="sbt-frame">
  <div class="sbt-scroll" id="sbtScroll">
    <article class="sbt-body">
      <h1>夜更けの焙煎日記 — 6月のエチオピア</h1>
      <p>今月のシングルオリジンは、エチオピア・イルガチェフェの浅煎り。ベリーのような明るい酸味が特徴です。</p>
      <p>少し下へ進むと、右下に「トップへ戻る」ボタンが現れます。周囲のリングが読み進み度を表します。</p>
      <p>焙煎は深夜の静かな時間に。豆のはぜる音に耳をすませ、止めどきを見極めます。</p>
      <p>抽出は92℃、30秒の蒸らしから。冷めていく過程で表情が変わるのも、浅煎りの楽しみです。</p>
      <p>このサンプルは自動で上下に往復し、ボタンの出現とリングの充填を実演します。</p>
      <p>来月はコロンビアの中煎りを予定。続きは焙煎日記でお楽しみください。</p>
    </article>
  </div>

  <button class="sbt-btn" id="sbtBtn" type="button" aria-label="トップへ戻る">
    <svg class="sbt-ring" viewBox="0 0 48 48" aria-hidden="true">
      <circle class="sbt-ring__bg" cx="24" cy="24" r="21"></circle>
      <circle class="sbt-ring__fg" id="sbtRing" cx="24" cy="24" r="21"></circle>
    </svg>
    <span class="sbt-arrow">↑</span>
  </button>
</div>
CSS
/* MOON BREW(カフェ):戻る↑ボタン(進捗リング付き)の再スキン */
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }

.sbt-frame { position: relative; width: 100%; height: 380px; overflow: hidden; background: #faf5ec; }

.sbt-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.sbt-body { padding: 26px 28px 90px; color: #3c2f22; line-height: 1.85; max-width: 600px; }
.sbt-body h1 { font-size: 23px; font-weight: 800; margin: 0 0 14px; font-family: "Hiragino Mincho ProN", serif; }
.sbt-body p { margin: 0 0 15px; font-size: 14px; color: #5b4630; }

.sbt-btn {
  position: absolute; right: 18px; bottom: 18px; z-index: 10;
  width: 54px; height: 54px; padding: 0; cursor: pointer; border: none; border-radius: 50%;
  background: #fffdf8; box-shadow: 0 10px 26px rgba(90,60,30,.2); display: grid; place-items: center;
  opacity: 0; transform: translateY(14px) scale(.85); pointer-events: none;
  transition: opacity .3s ease, transform .3s ease;
}
.sbt-btn.is-shown { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.sbt-btn:hover { transform: translateY(-3px) scale(1.04); }
.sbt-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.sbt-ring circle { fill: none; stroke-width: 3; stroke-linecap: round; }
.sbt-ring__bg { stroke: #ece1cd; }
.sbt-ring__fg { stroke: #b07a3f; stroke-dasharray: 131.95; stroke-dashoffset: 131.95; transition: stroke-dashoffset .1s linear; }
.sbt-arrow { font-size: 20px; font-weight: 700; color: #7a4f2a; line-height: 1; }

@media (prefers-reduced-motion: reduce) { .sbt-btn { transition: none; } .sbt-ring__fg { transition: none; } }
JavaScript
// (デモと同じフックを流用)進捗リング更新+しきい値で出現、クリックでトップへ
(() => {
  const sc = document.getElementById('sbtScroll');
  const btn = document.getElementById('sbtBtn');
  const ring = document.getElementById('sbtRing');
  if (!sc || !btn || !ring) return;
  const CIRC = 131.95;
  function update() {
    const max = sc.scrollHeight - sc.clientHeight;
    const ratio = max > 0 ? Math.min(sc.scrollTop / max, 1) : 0;
    ring.style.strokeDashoffset = String(CIRC * (1 - ratio));
    btn.classList.toggle('is-shown', sc.scrollTop > 70);
  }
  let ticking = false;
  sc.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { update(); ticking = false; }); }, { passive: true });
  update();
  btn.addEventListener('click', () => { auto = false; sc.scrollTo({ top: 0, behavior: 'smooth' }); });
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
  ['wheel', 'touchstart'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = sc.scrollHeight - sc.clientHeight;
      sc.scrollTop += dir * 1.5;
      if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
      requestAnimationFrame(step);
    }, 1100);
  }
})();

実装ガイド

使いどころ

長いページ全般のユーティリティとして。一定量スクロールすると現れる「トップへ戻る」ボタンで、周囲のリングが読み進み度を示し、押すと最上部へ滑らかに戻ります。

実装時の注意点

scrollTop ÷(全体高 − ビューポート高)の割合を SVG リングの stroke-dashoffset に反映し、しきい値超えで is-shown を付与。出現は opacity+scale の控えめなフェードです。クリックで smooth スクロールしてトップへ戻ります。

対応ブラウザ

SVG stroke アニメーション・smooth scroll・transform は全モダンブラウザで対応します。リングの円周は 2πr から算出しています。

よくある失敗

リングの dasharray は半径に一致させないと満タンにならない(r=21 → 約131.95)。出現しきい値が低すぎると常時表示になり鬱陶しいので、1画面分は過ぎてから出すのが目安。固定ボタンは他のUI(チャット等)と重ならない位置に。

応用例

リング無しのシンプル版、章ごとのジャンプ、押下で目次を開く、進捗100%で色を変えるなどのアレンジが可能です。

コード

HTML
<!-- 進捗リング付き「トップへ戻る」ボタン -->
<div class="sbt-frame">
  <div class="sbt-scroll" id="sbtScroll">
    <article class="sbt-body">
      <h1>戻る↑ボタン(進捗リング付き)</h1>
      <p>少し下へ進むと、右下に「トップへ戻る」ボタンが現れます。周囲のリングが読み進み度を表します。</p>
      <p>長いページで上部のナビへ素早く戻れる、定番のユーティリティです。出現は控えめなフェード&スケールで。</p>
      <p>リングは scrollTop ÷(全体高 − ビューポート高)で算出した割合を stroke-dashoffset に反映しています。</p>
      <p>ボタンを押すと最上部へ滑らかに戻ります。このデモは自動で上下に往復します。</p>
      <p>最下部まで来るとリングがほぼ満タンに。実装コストの低い割に効果的な仕上げです。</p>
      <p>以上、追従ユーティリティの王道でした。</p>
    </article>
  </div>

  <button class="sbt-btn" id="sbtBtn" type="button" aria-label="トップへ戻る">
    <svg class="sbt-ring" viewBox="0 0 48 48" aria-hidden="true">
      <circle class="sbt-ring__bg" cx="24" cy="24" r="21"></circle>
      <circle class="sbt-ring__fg" id="sbtRing" cx="24" cy="24" r="21"></circle>
    </svg>
    <span class="sbt-arrow">↑</span>
  </button>
</div>
CSS
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }

.sbt-frame { position: relative; width: 100%; height: 380px; overflow: hidden; background: #fbfbfd; }

.sbt-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.sbt-body { padding: 26px 28px 90px; color: #2b2d38; line-height: 1.85; max-width: 600px; }
.sbt-body h1 { font-size: 23px; font-weight: 800; margin: 0 0 14px; }
.sbt-body p { margin: 0 0 15px; font-size: 14px; color: #4b4e5b; }

.sbt-btn {
  position: absolute; right: 18px; bottom: 18px; z-index: 10;
  width: 54px; height: 54px; padding: 0; cursor: pointer;
  border: none; border-radius: 50%;
  background: #fff; box-shadow: 0 10px 26px rgba(20,24,40,.18);
  display: grid; place-items: center;
  opacity: 0; transform: translateY(14px) scale(.85); pointer-events: none;
  transition: opacity .3s ease, transform .3s ease;
}
.sbt-btn.is-shown { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.sbt-btn:hover { transform: translateY(-3px) scale(1.04); }

.sbt-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.sbt-ring circle { fill: none; stroke-width: 3; stroke-linecap: round; }
.sbt-ring__bg { stroke: #eceef5; }
.sbt-ring__fg { stroke: #6366f1; stroke-dasharray: 131.95; stroke-dashoffset: 131.95; transition: stroke-dashoffset .1s linear; }
.sbt-arrow { font-size: 20px; font-weight: 700; color: #4f46e5; line-height: 1; }

@media (prefers-reduced-motion: reduce) { .sbt-btn { transition: none; } .sbt-ring__fg { transition: none; } }
JavaScript
// 進捗リング更新+しきい値で出現、クリックでトップへ
(() => {
  const sc = document.getElementById('sbtScroll');
  const btn = document.getElementById('sbtBtn');
  const ring = document.getElementById('sbtRing');
  if (!sc || !btn || !ring) return;

  const CIRC = 131.95; // 2π * 21
  function update() {
    const max = sc.scrollHeight - sc.clientHeight;
    const ratio = max > 0 ? Math.min(sc.scrollTop / max, 1) : 0;
    ring.style.strokeDashoffset = String(CIRC * (1 - ratio));
    btn.classList.toggle('is-shown', sc.scrollTop > 70);
  }
  let ticking = false;
  sc.addEventListener('scroll', () => {
    if (ticking) return; ticking = true;
    requestAnimationFrame(() => { update(); ticking = false; });
  }, { passive: true });
  update();

  btn.addEventListener('click', () => { auto = false; sc.scrollTo({ top: 0, behavior: 'smooth' }); });

  // 自動で往復し、出現とリングの充填を実演
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
  ['wheel', 'touchstart'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = sc.scrollHeight - sc.clientHeight;
      sc.scrollTop += dir * 1.5;
      if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
      requestAnimationFrame(step);
    }, 1100);
  }
})();

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

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

# 追加してほしい効果
戻る↑ボタン(進捗リング付き)(追従ウィジェット)
一定量スクロールすると現れる「トップへ戻る」ボタン。周囲のリングが読み進み度を示し、押すと最上部へ滑らかに戻ります。長いページの定番ユーティリティをひとつにまとめました。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 進捗リング付き「トップへ戻る」ボタン -->
<div class="sbt-frame">
  <div class="sbt-scroll" id="sbtScroll">
    <article class="sbt-body">
      <h1>戻る↑ボタン(進捗リング付き)</h1>
      <p>少し下へ進むと、右下に「トップへ戻る」ボタンが現れます。周囲のリングが読み進み度を表します。</p>
      <p>長いページで上部のナビへ素早く戻れる、定番のユーティリティです。出現は控えめなフェード&スケールで。</p>
      <p>リングは scrollTop ÷(全体高 − ビューポート高)で算出した割合を stroke-dashoffset に反映しています。</p>
      <p>ボタンを押すと最上部へ滑らかに戻ります。このデモは自動で上下に往復します。</p>
      <p>最下部まで来るとリングがほぼ満タンに。実装コストの低い割に効果的な仕上げです。</p>
      <p>以上、追従ユーティリティの王道でした。</p>
    </article>
  </div>

  <button class="sbt-btn" id="sbtBtn" type="button" aria-label="トップへ戻る">
    <svg class="sbt-ring" viewBox="0 0 48 48" aria-hidden="true">
      <circle class="sbt-ring__bg" cx="24" cy="24" r="21"></circle>
      <circle class="sbt-ring__fg" id="sbtRing" cx="24" cy="24" r="21"></circle>
    </svg>
    <span class="sbt-arrow">↑</span>
  </button>
</div>

【CSS】
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }

.sbt-frame { position: relative; width: 100%; height: 380px; overflow: hidden; background: #fbfbfd; }

.sbt-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.sbt-body { padding: 26px 28px 90px; color: #2b2d38; line-height: 1.85; max-width: 600px; }
.sbt-body h1 { font-size: 23px; font-weight: 800; margin: 0 0 14px; }
.sbt-body p { margin: 0 0 15px; font-size: 14px; color: #4b4e5b; }

.sbt-btn {
  position: absolute; right: 18px; bottom: 18px; z-index: 10;
  width: 54px; height: 54px; padding: 0; cursor: pointer;
  border: none; border-radius: 50%;
  background: #fff; box-shadow: 0 10px 26px rgba(20,24,40,.18);
  display: grid; place-items: center;
  opacity: 0; transform: translateY(14px) scale(.85); pointer-events: none;
  transition: opacity .3s ease, transform .3s ease;
}
.sbt-btn.is-shown { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.sbt-btn:hover { transform: translateY(-3px) scale(1.04); }

.sbt-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.sbt-ring circle { fill: none; stroke-width: 3; stroke-linecap: round; }
.sbt-ring__bg { stroke: #eceef5; }
.sbt-ring__fg { stroke: #6366f1; stroke-dasharray: 131.95; stroke-dashoffset: 131.95; transition: stroke-dashoffset .1s linear; }
.sbt-arrow { font-size: 20px; font-weight: 700; color: #4f46e5; line-height: 1; }

@media (prefers-reduced-motion: reduce) { .sbt-btn { transition: none; } .sbt-ring__fg { transition: none; } }

【JavaScript】
// 進捗リング更新+しきい値で出現、クリックでトップへ
(() => {
  const sc = document.getElementById('sbtScroll');
  const btn = document.getElementById('sbtBtn');
  const ring = document.getElementById('sbtRing');
  if (!sc || !btn || !ring) return;

  const CIRC = 131.95; // 2π * 21
  function update() {
    const max = sc.scrollHeight - sc.clientHeight;
    const ratio = max > 0 ? Math.min(sc.scrollTop / max, 1) : 0;
    ring.style.strokeDashoffset = String(CIRC * (1 - ratio));
    btn.classList.toggle('is-shown', sc.scrollTop > 70);
  }
  let ticking = false;
  sc.addEventListener('scroll', () => {
    if (ticking) return; ticking = true;
    requestAnimationFrame(() => { update(); ticking = false; });
  }, { passive: true });
  update();

  btn.addEventListener('click', () => { auto = false; sc.scrollTo({ top: 0, behavior: 'smooth' }); });

  // 自動で往復し、出現とリングの充填を実演
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
  ['wheel', 'touchstart'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = sc.scrollHeight - sc.clientHeight;
      sc.scrollTop += dir * 1.5;
      if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
      requestAnimationFrame(step);
    }, 1100);
  }
})();

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

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