現在地ハイライト
縦に並ぶセクションをスクロールすると、サイドの目次の現在地が自動でハイライトされる。
プレビュー
操作枠内をスクロールすると動きます。
仕様書
- 向く場面
- 見出しが縦に続く長い仕様書・規約・レポート
- 目次を並べて現在地を示したい文書
- セクション4〜6個で各節に分量がある
- 避ける場面
- 各節が1〜2行で終わる短い一覧(FAQ等)
- 目次ラベルが長く狭い幅に収まらない
- ページ全体スクロールで root を差し替えない転用
- 構成要素
- 目次
- 目次リンク
- 現在地マーカー
- 本文セクション
- 持っている状態
- 通常
- 押下中
- キーボード
Tab で目次リンクへ・Enter で対象セクションへスクロール(a 標準)
- 支援技術
目次は nav(aria-label=目次)で本文より先に読まれる。現在地は色と左罫線に加え aria-current=location を同期。契約: 実ページでは本文へのスキップ導線を置く
- 動き
リンクの色・罫線・背景 0.2s。目次クリックは scrollTo({behavior:smooth}) でコンテナ内を移動。reduced-motion では auto
デザインの要点
- 現在地の判定は scroll イベントではなく IntersectionObserver。閾値 0.4 で「4割見えたら現在地」にし、境界で点滅させない
- ハイライトは色+左罫線+背景の3つを重ねる。色弱の人にも罫線で伝わり、aria-current=location で読み上げにも伝わる
- 最後のセクションに min-height を足す。短い末尾は 4 割が見えることがなく、一度も現在地にならない
- 目次クリックはコンテナ内の scrollTo に置き換える。href="#id" のままだとページ全体が飛び、固定高さの本文枠が置いていかれる
使いどころ
見出しが縦に連続する長めのドキュメント(仕様書、規約、レポート)で、読者が『いま自分がどのセクションを読んでいるか』を目次側でも把握できるようにしたいときに向く。サイドの目次が72px幅に収まる短いラベルを想定しており、見出しの数が4〜6個程度で各セクションがある程度の分量を持つ場合に効果的。逆に、セクションが1〜2行で終わる短い一覧(FAQなど)ではハイライトが目まぐるしく切り替わるだけで意味を持たない。またこのデモは.spy-content自体がoverflow-y:autoの独立したスクロールコンテナになっており、ページ全体のスクロールではなくコンテナ内スクロールを前提にしている。実際のページでウィンドウ全体をスクロールさせる構成にするなら、IntersectionObserverのrootをnull(ビューポート)に変える必要がある。
仕組み
現在地の検出はIntersectionObserverで行い、root: content(独自スクロールコンテナ)、threshold: 0.4を指定することで『セクションの40%が見えたら現在地とみなす』判定にしている。コールバックはentries.forEach内でentry.isIntersectingがtrueのものだけ処理し、対応するリンクにis-activeを付け、他の全リンクからis-activeを外す形。目次クリック時はpreventDefaultでhref="#…"のデフォルト遷移を止め、content.scrollTo({top: target.offsetTop, behavior:'smooth'})でコンテナ内だけをスムーズスクロールさせている(documentではなくcontentへのscrollToである点が通常のページ内リンクジャンプと異なる)。.spy-section:last-childにはmin-height:200pxを別途与えており、これは最後のセクションが短いとビューポート内で40%を満たせず、一度もハイライトされないまま終わる問題を避けるための調整。
実装の注意
threshold: 0.4は『40%以上見えている間ずっとtrue』という判定なので、隣接するセクションの境界付近では両方が同時に0.4を超えることがある。IntersectionObserverのコールバックは複数entryをまとめて配列で返すため、forEach内で両方is-activeが付き、配列の後ろ側(通常は下のセクション)が最終的に上書きして残る。単一のセクションだけを厳密にハイライトしたいなら、entries全体からintersectionRatioが最大のものだけを選ぶロジックに直す必要がある。もう一つ、isIntersectingがfalseになったときにis-activeを外す処理がないため、ハイライトの消灯は『別のセクションがtrueになって上書きされる』ことに依存している。スクロール末端に達したあと、どのセクションも0.4を割り込むと、直前のis-activeがそのまま残り続ける(意図した挙動かどうかは要件次第で判断が必要)。
アクセシビリティ
現在地はクラスの見た目だけでなくaria-currentでも伝えている。スクリーンリーダー利用者は色の変化を知覚できないため、IntersectionObserverのコールバック内でis-activeの切り替えと同時にaria-current="location"を対象リンクへ付け、非アクティブなリンクからは属性ごと外す(setActive()の一箇所に集約)。これで目次のどの項目が現在地かが音声でも判別できる。目次のnavにはaria-label="目次"を付け、ランドマーク一覧から本文と区別できるようにしてある。弱点はキーボード利用者向けの導線で、.spy-tocがDOM上.spy-contentより先に来るため、Tabキーで本文へ到達する前に必ず目次の全リンクを順番にたどることになり、スキップするためのリンク(『本文へスキップ』のようなショートカット)が用意されていない。見出し数が多い実ページに応用する際は、目次の先頭にスキップリンクを1つ足すか、目次を独立したランドマークとして案内し、ランドマーク間ジャンプで本文へ直接飛べることを利用者に伝える工夫が要る。
調整して使う
調整できる値はthreshold(現在0.4。低くすると早めに切り替わり、高くすると遅れて切り替わる)、目次の位置(左サイドが定番だが右サイドや上部固定タブに変えることもできる)、そしてスクロール方式(コンテナ内scrollかページ全体scrollか)。ハイライトを単純な文字色の変化ではなく、tabs-underlineのような下線が縦に動くインジケータへ発展させる表現もできる。見出しの階層(h2/h3のネスト)がある文書では、目次側もインデント付きの階層リストにし、IntersectionObserverの監視対象をh2だけでなくh3にも広げる拡張が考えられるが、その場合はthresholdの調整だけでなく、どの階層を優先してハイライトするかのロジックを別途組む必要がある。
コード(コピーして使えます)
<div class="spy-root">
<nav class="spy-toc" aria-label="目次">
<a class="spy-link is-active" href="#spy-s1" data-target="spy-s1" aria-current="location">はじめに</a>
<a class="spy-link" href="#spy-s2" data-target="spy-s2">概要</a>
<a class="spy-link" href="#spy-s3" data-target="spy-s3">仕様</a>
<a class="spy-link" href="#spy-s4" data-target="spy-s4">まとめ</a>
</nav>
<div class="spy-content">
<section id="spy-s1" class="spy-section">
<h2>はじめに</h2>
<p>目次の現在地がハイライトされます。</p>
</section>
<section id="spy-s2" class="spy-section">
<h2>概要</h2>
<p>IntersectionObserverで実装しています。</p>
</section>
<section id="spy-s3" class="spy-section">
<h2>仕様</h2>
<p>外部ライブラリ不要のVanilla JSです。</p>
</section>
<section id="spy-s4" class="spy-section">
<h2>まとめ</h2>
<p>スクロールに連動して目次が追従します。</p>
</section>
</div>
</div>