DEMO
長い記事では、今どこを読んでいるのかが分からなくなりがちです。目次で現在地を示すと、全体の中の位置がひと目で分かります。
本文をスクロールして、左の目次の変化を確かめてください。
見出しごとに id を付け、目次のリンクから href で指します。リンクだけでも移動できるので、JavaScript が動かない環境でも目次として使えます。
本文の枠には tabindex を付けて、キーボードでもスクロールできるようにします。
Intersection Observer で、各セクションが枠の上から4割の範囲に入っているかを見張ります。入っている中で最も下のセクションを「今読んでいる見出し」とします。
現在地のリンクには aria-current を付け、左の線を transform で移動させます。
いちばん下までスクロールした時に、最後の見出しが選ばれるかを確かめます。最後の見出しが短いと、枠の上の範囲まで届かないためです。
目次のリンクを押した時に、見出しへ移動してフォーカスも移るかを確かめます。
現在地が分かる目次は、長い記事やドキュメントの読みやすさを大きく上げます。動きは線の移動だけにとどめ、本文の邪魔をしないようにします。
CODE
<div class="demo-stage ssp-stage">
<div class="ssp-box">
<nav class="ssp-toc" aria-label="目次">
<span class="ssp-ind" id="ssp-ind" aria-hidden="true"></span>
<ol class="ssp-list">
<li><a class="ssp-link" href="#ssp-s1">はじめに</a></li>
<li><a class="ssp-link" href="#ssp-s2">準備</a></li>
<li><a class="ssp-link" href="#ssp-s3">実装</a></li>
<li><a class="ssp-link" href="#ssp-s4">検証</a></li>
<li><a class="ssp-link" href="#ssp-s5">まとめ</a></li>
</ol>
</nav>
<div class="ssp-body" id="ssp-body" tabindex="0" role="region" aria-label="本文(スクロールできます)">
<section class="ssp-sec" id="ssp-s1" tabindex="-1" aria-labelledby="ssp-h1"><h3 id="ssp-h1">はじめに</h3><p>長い記事では、今どこを読んでいるのかが分からなくなりがちです。目次で現在地を示すと、全体の中の位置がひと目で分かります。</p><p>本文をスクロールして、左の目次の変化を確かめてください。</p></section>
<section class="ssp-sec" id="ssp-s2" tabindex="-1" aria-labelledby="ssp-h2"><h3 id="ssp-h2">準備</h3><p>見出しごとに id を付け、目次のリンクから href で指します。リンクだけでも移動できるので、JavaScript が動かない環境でも目次として使えます。</p><p>本文の枠には tabindex を付けて、キーボードでもスクロールできるようにします。</p></section>
<section class="ssp-sec" id="ssp-s3" tabindex="-1" aria-labelledby="ssp-h3"><h3 id="ssp-h3">実装</h3><p>Intersection Observer で、各セクションが枠の上から4割の範囲に入っているかを見張ります。入っている中で最も下のセクションを「今読んでいる見出し」とします。</p><p>現在地のリンクには aria-current を付け、左の線を transform で移動させます。</p></section>
<section class="ssp-sec" id="ssp-s4" tabindex="-1" aria-labelledby="ssp-h4"><h3 id="ssp-h4">検証</h3><p>いちばん下までスクロールした時に、最後の見出しが選ばれるかを確かめます。最後の見出しが短いと、枠の上の範囲まで届かないためです。</p><p>目次のリンクを押した時に、見出しへ移動してフォーカスも移るかを確かめます。</p></section>
<section class="ssp-sec ssp-last" id="ssp-s5" tabindex="-1" aria-labelledby="ssp-h5"><h3 id="ssp-h5">まとめ</h3><p>現在地が分かる目次は、長い記事やドキュメントの読みやすさを大きく上げます。動きは線の移動だけにとどめ、本文の邪魔をしないようにします。</p></section>
</div>
</div>
</div>
.ssp-stage { display:flex; align-items:center; justify-content:center; padding:1.2rem; background:#0B0B10; min-height:340px; }
.ssp-box { width:100%; max-width:520px; display:grid; grid-template-columns:120px 1fr; gap:1rem; }
.ssp-toc { position:relative; align-self:start; padding-left:.8rem; border-left:2px solid rgba(255,255,255,.1); }
.ssp-ind { position:absolute; left:-2px; top:0; width:2px; height:36px; background:#E91B89; transition: transform .3s cubic-bezier(.22,1,.36,1); }
.ssp-list { list-style:none; margin:0; padding:0; }
.ssp-link { display:flex; align-items:center; min-height:36px; color:#A1A1AA; font-size:.8rem; text-decoration:none; transition: color .2s ease; }
.ssp-link[aria-current="true"] { color:#fff; font-weight:600; }
.ssp-link:focus-visible { outline:2px solid #E91B89; outline-offset:2px; border-radius:4px; }
.ssp-body { height:300px; overflow-y:auto; padding:0 1rem; border-radius:12px; background:#111117; border:1px solid rgba(255,255,255,.08); outline:none; }
.ssp-body:focus-visible { box-shadow:0 0 0 2px #E91B89; }
.ssp-sec { padding:1rem 0 1.2rem; border-bottom:1px solid rgba(255,255,255,.06); color:#C9C9D1; font-size:.82rem; line-height:1.8; outline:none; }
.ssp-sec.ssp-last { min-height:240px; border-bottom:0; }
.ssp-sec:focus-visible { box-shadow:inset 0 0 0 2px #E91B89; }
.ssp-sec h3 { margin:0 0 .4rem; color:#fff; font-size:1rem; }
.ssp-sec p { margin:0 0 .6rem; }
@media (max-width: 480px) {
.ssp-box { grid-template-columns:1fr; }
}
@media (prefers-reduced-motion: reduce) {
.ssp-ind, .ssp-link { transition-duration:.01ms; }
}
(function(){
var body = document.getElementById('ssp-body');
var ind = document.getElementById('ssp-ind');
if (!body || !ind || !window.IntersectionObserver) return;
var links = Array.prototype.slice.call(document.querySelectorAll('.ssp-link'));
var secs = links.map(function(a){ return document.getElementById(a.getAttribute('href').slice(1)); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var inBand = new Set(), cur = -1, ticking = false;
function setActive(i){
if (i === cur) return;
cur = i;
links.forEach(function(a, k){ if (k === i) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current'); });
ind.style.height = links[i].offsetHeight + 'px';
ind.style.transform = 'translateY(' + links[i].offsetTop + 'px)';
}
function pick(){
ticking = false;
if (body.scrollTop + body.clientHeight >= body.scrollHeight - 2) { setActive(secs.length - 1); return; }
for (var i = secs.length - 1; i >= 0; i--) if (inBand.has(secs[i])) { setActive(i); return; }
}
var io = new IntersectionObserver(function(entries){
entries.forEach(function(en){ if (en.isIntersecting) inBand.add(en.target); else inBand.delete(en.target); });
pick();
}, { root: body, rootMargin: '0px 0px -60% 0px' });
secs.forEach(function(s){ io.observe(s); });
body.addEventListener('scroll', function(){ if (!ticking) { ticking = true; requestAnimationFrame(pick); } }, { passive: true });
links.forEach(function(a, i){
a.addEventListener('click', function(e){
e.preventDefault();
var top = body.scrollTop + secs[i].getBoundingClientRect().top - body.getBoundingClientRect().top;
body.scrollTo({ top: top, behavior: reduce.matches ? 'auto' : 'smooth' });
secs[i].focus({ preventScroll: true });
});
});
setActive(0);
})();
AI PROMPT
本文をスクロールすると、今読んでいる見出しが目次で強調され、左の線が現在地へすべって移動する目次(Scroll Spy)を作って。左に nav(aria-label="目次")の ol で目次を置き、各リンクは href="#見出しのid" で本文の section を指す(JavaScript が動かなくても目次として使えるように)。右の本文は高さ300px・overflow-y:auto の枠で、tabindex="0"・role="region"・aria-label を付ける。Intersection Observer を、root に本文の枠、rootMargin に '0px 0px -60% 0px' を指定して作り(枠の上から4割の範囲だけを見張る)、各 section を observe する。範囲に入っている section を Set で持ち、その中で最も下の(番号が大きい)section=見出しが上から4割の線を越えた最後の section を「今読んでいる見出し」とする。ただし本文をいちばん下までスクロールした時は、最後の見出しを選ぶ(最後の見出しが短いと上の範囲まで届かないため)。スクロールのたびに requestAnimationFrame で1フレームに1回だけ判定する。今読んでいる見出しのリンクには aria-current="true" を付けて文字を白く太くし、目次の左の線(高さはリンク1行分)を transform: translateY(リンクの offsetTop) で .3秒かけて移動させる。目次のリンクを押したら preventDefault し、本文の枠を scrollTo の behavior: 'smooth' でその見出しの位置まで動かし、見出しの section(tabindex="-1")へ preventScroll 付きでフォーカスを移す。幅が狭い画面では目次を本文の上に置く。prefers-reduced-motion: reduce の時はスクロールを即座にし、線の移動の transition を無効化する。 Acceptance criteria: works with prefers-reduced-motion (animation disabled), zero console errors, zero layout shift
AIエージェントでの実装ガイド → Claude Code でWebアニメーションを実装する
BOOKS · PR · AMAZON
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
学びを止めない · AMAZONサブスク
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
CREATOR TOOLS · スクール(PR)
※本サイトはアフィリエイトリンクを含みます。リンク経由のご購入・お申し込みで運営者が紹介料を受け取る場合があります。
本文をスクロールすると、今読んでいる見出しに合わせて目次の強調が移っていく仕組みです。Bootstrap にも Scrollspy という部品があり、公式ドキュメントでは「スクロール位置に応じてナビゲーションを自動で更新し、ビューポート内で現在アクティブなリンクを示す」ものと説明されています。
当サイトの Scroll Progress Bar はスクロール量に応じて上部のバーが伸びる読了表示、Back to Top Button は少しスクロールすると現れ読んだ割合をリングで示すボタン、Sticky Sections はセクションが画面上部に張り付き次のセクションに押し出される演出です。本ページは全体の割合ではなく「どの見出しにいるか」を示す別テーマです。
技術記事やドキュメント、長い規約のページのように、見出しの多いページで効果を発揮します。目次のリンクは JavaScript がなくても移動できる普通のリンクとして作り、そのうえで現在地の表示を足すと、どんな環境でも目次として使えます。
scroll イベントで位置を計算する方法と何が違いますか?
scroll イベントのたびに全ての見出しの位置を測ると、スクロール中の処理が増え、スタイルの変更と重なると強制的なレイアウトの計算も起きます。MDN の説明では、Intersection Observer は対象の要素と祖先の要素(または画面)との重なりの変化を非同期で見張る仕組みで、重なりが変わった時だけ知らせてくれます。本実装は重なりの判定を Intersection Observer に任せ、スクロール時は見出しごとの位置を測らず、いちばん下まで来たかの判定と、記録済みの結果からの選択だけを行っています。MDN の互換性データでは Chrome 51・Edge 15・Firefox 55・Safari 12.1 から使えるとされています。
現在地のリンクに aria-current を付けるのはなぜですか?
文字の色や太さだけで現在地を示すと、読み上げソフトの利用者には伝わりません。aria-current を付けると、目次の中でどのリンクが「現在の項目」なのかが読み上げでも伝わります。本実装は目次という集合の中の現在の項目を示すため、値に true を使っています。
コードは無料で使えますか?
はい。Motion LabのコードスニペットとAIプロンプトは、クレジット表記なしで自由に使えます。コピーしてそのままプロジェクトに利用できます。