Scrollspy
stimeo--scrollspy
IntersectionObserver-driven scroll tracker matching active section states to Table of Contents anchors.
The stimeo--scrollspy controller synchronizes Table of Contents (TOC) links to scroll viewport sections. By harnessing the native IntersectionObserver API, it avoids heavy scroll event handlers entirely, ensuring 60 FPS scrolling rendering performance. Features a robust nearest-top calculation algorithm prioritizing the section closest to the trigger margin line, and manages the aria-current=\"location\" state on links gracefully.
1. Introduction
This is the introduction section. Scroll down to observe the TOC active highlights tracking your viewport progress dynamically.
2. Basic Usage
Setting up a scrollspy only requires data-controller="stimeo--scrollspy" on the nav and data-stimeo--scrollspy-target="link" on each anchor element.
3. API Reference
Use the offset-value and root-margin-value parameters to precisely align the trigger zone to match your dynamic sticky headers or padding grids.
Keyboard
| Key | Action |
|---|---|
| Scroll | As the user scrolls, the link targeting the active section is automatically decorated with aria-current="location" and dispatches 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;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--scrollspy"
Targets
| Name | Description | Attribute |
|---|---|---|
link
required
|
A nav link whose href anchors a section; the spy marks the one for the section in view with aria-current="location". |
data-stimeo--scrollspy-target="link" |
Values
| Name | Description | Attribute |
|---|---|---|
offset
|
Pixels below the scroll-root top where the active trigger line sits; default 0. | data-stimeo--scrollspy-offset-value |
rootMargin
|
Custom IntersectionObserver rootMargin; empty derives it from offset. |
data-stimeo--scrollspy-root-margin-value |
rootSelector
|
Selector for a nested scroll container to observe within; empty uses the viewport. | data-stimeo--scrollspy-root-selector-value |
Actions
| Name | Description | Action |
|---|---|---|
scrollTo
|
Smoothly scrolls to the link's anchored section, honoring offset and any nested root container. | stimeo--scrollspy#scrollTo |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when the active section changes; detail carries { id, link }. |
stimeo--scrollspy:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-current |
TOC anchor link target | Annotated with "location" when the targeted section is closest to the offset trigger line. |