追従目次(現在地ハイライト)
本文の横に固定した目次が、スクロール位置に応じて現在の見出しを自動でハイライトします。長文記事やドキュメントの回遊性を高める scrollspy の定番。クリックで該当位置へジャンプもできます。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk:ヘルプドキュメントの追従目次 -->
<div class="stoc-frame">
<aside class="stoc-toc" aria-label="目次">
<p class="stoc-toc__h">目次</p>
<a href="#" onclick="return false" data-to="s1" class="is-active">はじめに</a>
<a href="#" onclick="return false" data-to="s2">プロジェクト作成</a>
<a href="#" onclick="return false" data-to="s3">メンバー招待</a>
<a href="#" onclick="return false" data-to="s4">連携設定</a>
</aside>
<div class="stoc-scroll" id="stocScroll">
<article class="stoc-body">
<section id="s1"><h2>はじめに</h2><p>FlowDesk のはじめ方ガイドです。アカウント作成から最初のプロジェクト立ち上げまでを順に説明します。</p><p>読み進めると左の目次の現在地が切り替わります。</p></section>
<section id="s2"><h2>プロジェクト作成</h2><p>ダッシュボード右上の「新規プロジェクト」から、テンプレートを選んで開始できます。</p><p>名称と公開範囲を決めれば準備完了です。</p></section>
<section id="s3"><h2>メンバー招待</h2><p>設定 > メンバーからメールで招待します。権限は閲覧/編集/管理の3種です。</p><p>招待リンクの有効期限も設定できます。</p></section>
<section id="s4"><h2>連携設定</h2><p>カレンダーやチャットツールと接続すると、通知と予定が自動で同期します。</p><p>以上で初期設定は完了です。</p></section>
</article>
</div>
</div>
CSS
/* FlowDesk(SaaS):追従目次の再スキン */
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
.stoc-frame { position: relative; width: 100%; height: 380px; overflow: hidden; display: grid; grid-template-columns: 178px 1fr; background: #fbfcff; }
.stoc-toc { padding: 22px 16px; border-right: 1px solid #e8ebf5; background: #fff; }
.stoc-toc__h { margin: 0 0 12px; font-size: 11px; letter-spacing: .12em; font-weight: 700; color: #97a0bf; text-transform: uppercase; }
.stoc-toc a { display: block; position: relative; font-size: 13px; font-weight: 600; color: #69718f; text-decoration: none; padding: 8px 10px; border-radius: 8px; margin-bottom: 2px; transition: color .2s ease, background .2s ease; }
.stoc-toc a::before { content: ""; position: absolute; left: -16px; top: 50%; transform: translateY(-50%); width: 3px; height: 0; border-radius: 2px; background: #4f6bff; transition: height .25s ease; }
.stoc-toc a.is-active { color: #4f6bff; background: #eef1ff; }
.stoc-toc a.is-active::before { height: 22px; }
.stoc-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.stoc-body { padding: 24px 28px 120px; color: #2a3050; line-height: 1.85; }
.stoc-body section { padding-top: 8px; }
.stoc-body h2 { font-size: 21px; font-weight: 800; margin: 18px 0 12px; }
.stoc-body section:first-child h2 { margin-top: 0; }
.stoc-body p { margin: 0 0 14px; max-width: 520px; font-size: 14px; color: #4a5172; }
@media (prefers-reduced-motion: reduce) { .stoc-toc a, .stoc-toc a::before { transition: none; } }
JavaScript
// (デモと同じフックを流用)スクロール位置から現在地を判定し目次をハイライト
(() => {
const sc = document.getElementById('stocScroll');
if (!sc) return;
const links = Array.from(sc.closest('.stoc-frame').querySelectorAll('.stoc-toc a'));
const sections = links.map(a => sc.querySelector('#' + a.dataset.to)).filter(Boolean);
links.forEach((a, i) => a.addEventListener('click', () => { auto = false; if (sections[i]) sc.scrollTo({ top: sections[i].offsetTop - 12, behavior: 'smooth' }); }));
function spy() {
const pos = sc.scrollTop + 40; let idx = 0;
sections.forEach((s, i) => { if (s.offsetTop <= pos) idx = i; });
links.forEach((a, i) => a.classList.toggle('is-active', i === idx));
}
let ticking = false;
sc.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { spy(); ticking = false; }); }, { passive: true });
spy();
let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
['wheel', 'touchstart', 'pointerdown'].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.4;
if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
requestAnimationFrame(step);
}, 1000);
}
})();
実装ガイド
使いどころ
長文記事・ヘルプ・ドキュメントなど、章立てのあるページに。本文の横に固定した目次が、スクロール位置に応じて現在の見出しを自動でハイライトし、クリックで該当位置へ移動できます。
実装時の注意点
各セクションの offsetTop と現在の scrollTop を比較し、上端に最も近いセクションを現在地とみなして目次にクラスを付けます(scrollspy の簡易版)。スクロール監視は requestAnimationFrame で間引き。プレビューでは内部スクロール領域を基準にしていますが、実サイトでは IntersectionObserver の利用が堅実です。
対応ブラウザ
Scroll イベント・offsetTop・smooth scroll は全モダンブラウザで対応します。scroll-behavior:smooth は一部古い Safari で無効ですが、致命的ではありません。
よくある失敗
見出しが密集していると現在地が頻繁に切り替わってチラつくため、判定に少しオフセット(上端から40px等)を持たせます。可変の固定ヘッダーがある場合はその高さ分を引くこと。モバイルでは目次を上部の折りたたみやドロワーに切り替える設計が必要です。
応用例
ネスト見出し(h2/h3)の階層表示、現在地のプログレス表示、目次のスクロール追従、完了済みセクションのチェック表示などに展開できます。
コード
HTML
<!-- 追従目次+現在地ハイライト -->
<div class="stoc-frame">
<aside class="stoc-toc" aria-label="目次">
<p class="stoc-toc__h">目次</p>
<a href="#" onclick="return false" data-to="s1" class="is-active">はじめに</a>
<a href="#" onclick="return false" data-to="s2">設計の原則</a>
<a href="#" onclick="return false" data-to="s3">実装のコツ</a>
<a href="#" onclick="return false" data-to="s4">まとめ</a>
</aside>
<div class="stoc-scroll" id="stocScroll">
<article class="stoc-body">
<section id="s1"><h2>はじめに</h2><p>追従目次は、長い文章の中で「いま自分がどこを読んでいるか」を示し、目的の章へ素早く移動させます。</p><p>本文のスクロールに合わせて、左の目次のハイライトが切り替わります。</p></section>
<section id="s2"><h2>設計の原則</h2><p>見出しの位置を監視し、ビューポート上部に最も近いセクションを「現在地」とみなします。</p><p>章が切り替わるたび、対応する項目に色が移ります。</p></section>
<section id="s3"><h2>実装のコツ</h2><p>IntersectionObserver で各セクションの可視状態を取り、しきい値で現在地を決めると軽量です。</p><p>このデモはスクロール位置から判定する簡易版です。</p></section>
<section id="s4"><h2>まとめ</h2><p>追従目次は実装コストが低く、読み物系サイトのUXを確実に底上げします。</p><p>最後まで来ると、目次の最終項目が点灯します。</p></section>
</article>
</div>
</div>
CSS
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
.stoc-frame {
position: relative; width: 100%; height: 380px; overflow: hidden;
display: grid; grid-template-columns: 168px 1fr;
background: #fbfbfd;
}
.stoc-toc {
padding: 22px 16px; border-right: 1px solid #ececf2; background: #fff;
}
.stoc-toc__h { margin: 0 0 12px; font-size: 11px; letter-spacing: .12em; font-weight: 700; color: #9aa0b0; text-transform: uppercase; }
.stoc-toc a {
display: block; position: relative;
font-size: 13px; font-weight: 600; color: #6b7180; text-decoration: none;
padding: 8px 10px; border-radius: 8px; margin-bottom: 2px;
transition: color .2s ease, background .2s ease;
}
.stoc-toc a::before {
content: ""; position: absolute; left: -16px; top: 50%; transform: translateY(-50%);
width: 3px; height: 0; border-radius: 2px; background: #6366f1; transition: height .25s ease;
}
.stoc-toc a.is-active { color: #4f46e5; background: #eef0fe; }
.stoc-toc a.is-active::before { height: 22px; }
.stoc-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.stoc-body { padding: 24px 28px 120px; color: #2b2d38; line-height: 1.85; }
.stoc-body section { padding-top: 8px; }
.stoc-body h2 { font-size: 21px; font-weight: 800; margin: 18px 0 12px; }
.stoc-body section:first-child h2 { margin-top: 0; }
.stoc-body p { margin: 0 0 14px; max-width: 520px; font-size: 14px; color: #4b4e5b; }
@media (prefers-reduced-motion: reduce) { .stoc-toc a, .stoc-toc a::before { transition: none; } }
JavaScript
// スクロール位置から現在のセクションを判定し、目次をハイライト
(() => {
const sc = document.getElementById('stocScroll');
if (!sc) return;
const links = Array.from(sc.closest('.stoc-frame').querySelectorAll('.stoc-toc a'));
const sections = links.map(a => sc.querySelector('#' + a.dataset.to)).filter(Boolean);
// 目次クリックでスクロール
links.forEach((a, i) => a.addEventListener('click', () => {
auto = false;
if (sections[i]) sc.scrollTo({ top: sections[i].offsetTop - 12, behavior: 'smooth' });
}));
function spy() {
const pos = sc.scrollTop + 40;
let idx = 0;
sections.forEach((s, i) => { if (s.offsetTop <= pos) idx = i; });
links.forEach((a, i) => a.classList.toggle('is-active', i === idx));
}
let ticking = false;
sc.addEventListener('scroll', () => {
if (ticking) return; ticking = true;
requestAnimationFrame(() => { spy(); ticking = false; });
}, { passive: true });
spy();
// 現在地の移り変わりが見えるよう往復オートスクロール
let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
['wheel', 'touchstart', 'pointerdown'].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.4;
if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
requestAnimationFrame(step);
}, 1000);
}
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「追従目次(現在地ハイライト)」の効果を追加してください。
# 追加してほしい効果
追従目次(現在地ハイライト)(追従ウィジェット)
本文の横に固定した目次が、スクロール位置に応じて現在の見出しを自動でハイライトします。長文記事やドキュメントの回遊性を高める scrollspy の定番。クリックで該当位置へジャンプもできます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 追従目次+現在地ハイライト -->
<div class="stoc-frame">
<aside class="stoc-toc" aria-label="目次">
<p class="stoc-toc__h">目次</p>
<a href="#" onclick="return false" data-to="s1" class="is-active">はじめに</a>
<a href="#" onclick="return false" data-to="s2">設計の原則</a>
<a href="#" onclick="return false" data-to="s3">実装のコツ</a>
<a href="#" onclick="return false" data-to="s4">まとめ</a>
</aside>
<div class="stoc-scroll" id="stocScroll">
<article class="stoc-body">
<section id="s1"><h2>はじめに</h2><p>追従目次は、長い文章の中で「いま自分がどこを読んでいるか」を示し、目的の章へ素早く移動させます。</p><p>本文のスクロールに合わせて、左の目次のハイライトが切り替わります。</p></section>
<section id="s2"><h2>設計の原則</h2><p>見出しの位置を監視し、ビューポート上部に最も近いセクションを「現在地」とみなします。</p><p>章が切り替わるたび、対応する項目に色が移ります。</p></section>
<section id="s3"><h2>実装のコツ</h2><p>IntersectionObserver で各セクションの可視状態を取り、しきい値で現在地を決めると軽量です。</p><p>このデモはスクロール位置から判定する簡易版です。</p></section>
<section id="s4"><h2>まとめ</h2><p>追従目次は実装コストが低く、読み物系サイトのUXを確実に底上げします。</p><p>最後まで来ると、目次の最終項目が点灯します。</p></section>
</article>
</div>
</div>
【CSS】
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
.stoc-frame {
position: relative; width: 100%; height: 380px; overflow: hidden;
display: grid; grid-template-columns: 168px 1fr;
background: #fbfbfd;
}
.stoc-toc {
padding: 22px 16px; border-right: 1px solid #ececf2; background: #fff;
}
.stoc-toc__h { margin: 0 0 12px; font-size: 11px; letter-spacing: .12em; font-weight: 700; color: #9aa0b0; text-transform: uppercase; }
.stoc-toc a {
display: block; position: relative;
font-size: 13px; font-weight: 600; color: #6b7180; text-decoration: none;
padding: 8px 10px; border-radius: 8px; margin-bottom: 2px;
transition: color .2s ease, background .2s ease;
}
.stoc-toc a::before {
content: ""; position: absolute; left: -16px; top: 50%; transform: translateY(-50%);
width: 3px; height: 0; border-radius: 2px; background: #6366f1; transition: height .25s ease;
}
.stoc-toc a.is-active { color: #4f46e5; background: #eef0fe; }
.stoc-toc a.is-active::before { height: 22px; }
.stoc-scroll { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.stoc-body { padding: 24px 28px 120px; color: #2b2d38; line-height: 1.85; }
.stoc-body section { padding-top: 8px; }
.stoc-body h2 { font-size: 21px; font-weight: 800; margin: 18px 0 12px; }
.stoc-body section:first-child h2 { margin-top: 0; }
.stoc-body p { margin: 0 0 14px; max-width: 520px; font-size: 14px; color: #4b4e5b; }
@media (prefers-reduced-motion: reduce) { .stoc-toc a, .stoc-toc a::before { transition: none; } }
【JavaScript】
// スクロール位置から現在のセクションを判定し、目次をハイライト
(() => {
const sc = document.getElementById('stocScroll');
if (!sc) return;
const links = Array.from(sc.closest('.stoc-frame').querySelectorAll('.stoc-toc a'));
const sections = links.map(a => sc.querySelector('#' + a.dataset.to)).filter(Boolean);
// 目次クリックでスクロール
links.forEach((a, i) => a.addEventListener('click', () => {
auto = false;
if (sections[i]) sc.scrollTo({ top: sections[i].offsetTop - 12, behavior: 'smooth' });
}));
function spy() {
const pos = sc.scrollTop + 40;
let idx = 0;
sections.forEach((s, i) => { if (s.offsetTop <= pos) idx = i; });
links.forEach((a, i) => a.classList.toggle('is-active', i === idx));
}
let ticking = false;
sc.addEventListener('scroll', () => {
if (ticking) return; ticking = true;
requestAnimationFrame(() => { spy(); ticking = false; });
}, { passive: true });
spy();
// 現在地の移り変わりが見えるよう往復オートスクロール
let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, dir = 1;
['wheel', 'touchstart', 'pointerdown'].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.4;
if (sc.scrollTop >= max - 1) dir = -1; else if (sc.scrollTop <= 0) dir = 1;
requestAnimationFrame(step);
}, 1000);
}
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。