スクロールスパイ
stimeo--scrollspy
IntersectionObserver を利用した高効率な交差監視と、ページ上端の最寄セクションを優先判定する目次(TOC)同期システム。
stimeo--scrollspy コントローラは、ドキュメントのスクロール位置に応じて目次リンクのアクティブ状態を自動同期させます。軽量な IntersectionObserver API を活用することで、スクロール中のイベントハンドラを登録せず、レンダリングパフォーマンスに影響を与えない極めて高いスクロール効率を誇ります。複数のセクションが同時にビューポートに交差している場合でも、トリガーライン(offsetの値)に最も近い上端寄りのセクションを「現在地」として正確に優先判定するアルゴリズムを備え、アクティブなアンカーリンクへ aria-current="location" を自動付与・管理します。
1. はじめに
このセクションはドキュメントの「はじめに」を説明します。スクロールすると目次のアクティブ状態が追従します。
2. 使い方
スクロールスパイのセットアップは、navタグに data-controller="stimeo--scrollspy" を指定し、各見出しに data-stimeo--scrollspy-target="link" を指定するだけです。
3. APIリファレンス
オプションの offset-value や root-margin-value をカスタマイズすることで、アクティブ判定のトリガーラインをミリピクセル単位で微調整可能です。
キーボード操作
| キー | 動作 |
|---|---|
| Scroll | ページスクロールに応じて、目次の該当アンカーリンクに aria-current="location" が自動付与され、stimeo--scrollspy:change イベントを発火する。 |
<%# Markup for the scrollspy (table-of-contents scroll tracking) demo.
stimeo--scrollspy provides intersection monitoring via IntersectionObserver and
nearest-to-top-trigger detection, and automatically sets and manages
aria-current="location" on the active link. %>
<div class="scrollspy-demo">
<nav
class="scrollspy-demo__nav"
data-controller="stimeo--scrollspy"
data-stimeo--scrollspy-offset-value="20"
data-stimeo--scrollspy-root-selector-value=".scrollspy-demo__content"
aria-label="<%= t("components.scrollspy.demo.title") %>"
>
<div class="scrollspy-demo__nav-title"><%= t("components.scrollspy.demo.title") %></div>
<a
class="scrollspy-demo__link"
href="#toc-intro"
data-stimeo--scrollspy-target="link"
data-action="click->stimeo--scrollspy#scrollTo"
>
<%= t("components.scrollspy.demo.intro_title") %>
</a>
<a
class="scrollspy-demo__link"
href="#toc-usage"
data-stimeo--scrollspy-target="link"
data-action="click->stimeo--scrollspy#scrollTo"
>
<%= t("components.scrollspy.demo.usage_title") %>
</a>
<a
class="scrollspy-demo__link"
href="#toc-api"
data-stimeo--scrollspy-target="link"
data-action="click->stimeo--scrollspy#scrollTo"
>
<%= t("components.scrollspy.demo.api_title") %>
</a>
</nav>
<div class="scrollspy-demo__content">
<section id="toc-intro" class="scrollspy-demo__section">
<h2><%= t("components.scrollspy.demo.intro_title") %></h2>
<p><%= t("components.scrollspy.demo.intro_body") %></p>
<div class="scrollspy-demo__spacer"></div>
</section>
<section id="toc-usage" class="scrollspy-demo__section">
<h2><%= t("components.scrollspy.demo.usage_title") %></h2>
<p><%= t("components.scrollspy.demo.usage_body") %></p>
<div class="scrollspy-demo__spacer"></div>
</section>
<section id="toc-api" class="scrollspy-demo__section">
<h2><%= t("components.scrollspy.demo.api_title") %></h2>
<p><%= t("components.scrollspy.demo.api_body") %></p>
<div class="scrollspy-demo__spacer"></div>
</section>
</div>
</div>
/*
* Presentation-only styles for the scrollspy demo.
* The active link is styled via aria-current="location" (set by the library).
*/
.scrollspy-demo {
display: grid;
grid-template-columns: 200px 1fr;
gap: 2rem;
width: 100%;
height: 400px;
background: var(--surface-card);
border: 1px solid var(--border-default);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05);
}
.scrollspy-demo__nav {
display: flex;
flex-direction: column;
padding: 1.5rem;
background: var(--surface-subtle);
border-right: 1px solid var(--border-default);
gap: 0.5rem;
}
.scrollspy-demo__nav-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.scrollspy-demo__link {
font-size: 0.875rem;
color: var(--color-text-muted);
text-decoration: none;
padding: 0.375rem 0.75rem;
border-left: 2px solid transparent;
transition: all 0.15s ease;
border-radius: 0 4px 4px 0;
}
.scrollspy-demo__link:hover {
color: var(--fg);
background: var(--surface-subtle);
}
/* Active state attached by the script (aria-current). */
.scrollspy-demo__link[aria-current="location"] {
color: var(--accent);
font-weight: 600;
border-left-color: var(--accent);
background: rgba(var(--accent-rgb), 0.05);
}
/* The scrolling content area. */
.scrollspy-demo__content {
overflow-y: scroll;
padding: 1.5rem;
scroll-behavior: smooth;
}
.scrollspy-demo__section {
padding-bottom: 1rem;
}
.scrollspy-demo__section h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--fg);
margin-top: 0;
margin-bottom: 0.75rem;
}
.scrollspy-demo__section p {
font-size: 0.95rem;
line-height: 1.6;
color: var(--color-text-muted);
margin: 0;
}
.scrollspy-demo__spacer {
height: 200px;
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--scrollspy"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
link
必須
|
href がセクションを指すナビのリンク。表示中セクションのリンクに aria-current="location" を印付けする。 |
data-stimeo--scrollspy-target="link" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
offset
|
アクティブ判定のトリガー線がスクロールルート上端から下にあるピクセル数。既定値は 0。 | data-stimeo--scrollspy-offset-value |
rootMargin
|
IntersectionObserver の rootMargin を上書きする。空なら offset から導出する。 |
data-stimeo--scrollspy-root-margin-value |
rootSelector
|
監視対象の入れ子スクロールコンテナのセレクター。空ならビューポートを使う。 | data-stimeo--scrollspy-root-selector-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
scrollTo
|
リンクが指すセクションへ滑らかにスクロールする。offset と入れ子ルートコンテナを考慮する。 | stimeo--scrollspy#scrollTo |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
アクティブなセクションが変わると発火する。detail に { id, link } を含む。 |
stimeo--scrollspy:change |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-current |
目次アンカーリンク要素 (link) | 現在スクロール表示されているセクションに合致するリンクに対して "location" が設定され、それ以外は属性が削除される。 |