Scrollspy
stimeo--scrollspy
Moves the current mark in a table of contents as you read, without running on every scroll.
Keeps a table of contents pointing at the section you are reading. The detection is left to the browser, so no code runs on every scroll event. The current section is the one whose top sits closest to the trigger line, which you can push down by the height of a fixed header. While no section is inside the band, the nearest one stays marked, so the mark never disappears and leaves you lost. The current section is announced as well, and a mark you placed yourself is left alone.
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 offset-value and root-margin-value to move the trigger line in pixel steps, for example to place it just below a sticky header.
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 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;
}
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 trigger line sits; default 0. | data-stimeo--scrollspy-offset-value |
rootMargin
|
Custom IntersectionObserver rootMargin; empty derives it from offset. It moves the observed band only, never the trigger line. |
data-stimeo--scrollspy-root-margin-value |
rootSelector
|
Selector for a nested scroll container to observe within; empty (or no match) uses the viewport. | data-stimeo--scrollspy-root-selector-value |
focusSection
|
When true, scrollTo also moves the sequential focus starting point into the destination section; default false. |
data-stimeo--scrollspy-focus-section-value |
Actions
| Name | Description | Action |
|---|---|---|
scrollTo
|
Scrolls to the link's anchored section, honoring offset and any nested root container. Jumps instantly when reduced motion is requested, and leaves the URL fragment untouched. | 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 trigger line. Only that "location" value is removed again, so an author-owned aria-current survives. |