スクロールスパイ
stimeo--scrollspy
読んでいる位置に合わせて目次の現在地を移します。スクロールのたびに計算しません。
いま読んでいるところに合わせて、目次のどの項目が現在地かを切り替えます。判定はブラウザに任せているので、スクロールのたびに計算が走ることはありません。現在地とみなすのは、上端が判定の線にいちばん近いセクションです。線の位置は、固定ヘッダの高さぶんだけ下げるといった調整ができます。どのセクションも判定の帯に入っていない間も、いちばん近いものを現在地のままにするので、目次の印が消えて迷子になりません。現在地は読み上げにも伝わります。利用側が自分で付けた印は消しません。
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 watches the sections with IntersectionObserver and marks the one whose
top edge sits closest to the trigger line: offset px (20 here) below the top of the
scroll root, which is the .scrollspy-demo__content container rather than the viewport.
Intersecting sections win; when none intersects, the closest tracked section keeps the
highlight, so the table of contents never goes blank. The library sets
aria-current="location" on the matching link and takes only that value back from the
others, so an aria-current you set yourself survives. %>
<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>
<%# The content area scrolls but holds no focusable element of its own, so it needs an
explicit tab stop or keyboard-only users cannot scroll it (WCAG 2.1.1; Safari does not
make overflow containers implicitly focusable). role="region" is safe to add here
because the label below gives it an accessible name. %>
<div
class="scrollspy-demo__content"
tabindex="0"
role="region"
aria-label="<%= t("components.scrollspy.demo.viewport_label") %>"
>
<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);
}
.scrollspy-demo__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Active state attached by the script (aria-current). */
.scrollspy-demo__link[aria-current="location"] {
color: var(--accent-700);
font-weight: 600;
border-left-color: var(--accent);
background: rgba(var(--accent-rgb), 0.05);
}
/* The scrolling content area.
Deliberately no `scroll-behavior`: scrollTo passes "smooth" normally and
"instant" under prefers-reduced-motion, so the library owns both paths. */
.scrollspy-demo__content {
overflow-y: scroll;
padding: 1.5rem;
}
.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 |
focusSection
|
有効にすると scrollTo が移動先セクションへキーボードの続きの位置も移す。既定値は false。 |
data-stimeo--scrollspy-focus-section-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
scrollTo
|
リンクが指すセクションへスクロールする。offset と入れ子ルートコンテナを考慮し、動きを減らす設定のときは瞬時に移動する。URL のハッシュは変更しない。 | stimeo--scrollspy#scrollTo |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
アクティブなセクションが変わると発火する。detail に { id, link } を含む。 |
stimeo--scrollspy:change |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-current |
目次アンカーリンク要素 (link) | 現在スクロール表示されているセクションに合致するリンクに対して "location" が設定される。それ以外のリンクからは "location" だけを取り除くため、利用側が付けた aria-current の値はそのまま残る。 |