
This is a dependency-free scrollspy library that automatically highlights navigation links in your TOC (table of contents) based on scroll position.
The library watches where the user is on the page and applies a CSS class to the corresponding link in your navigation menu. This gives users a clear visual cue of their location within the content.
Features:
- Intelligent section detection: Automatically determines active sections based on viewport position.
- Nested navigation support: Handles hierarchical table of contents structures with parent-child relationships.
- Dynamic content compatibility: Works with both static content and dynamically updated DOM elements.
- Fixed header compensation: Configurable scroll offset to account for sticky navigation bars.
- DOM mutation observer: Optional automatic refresh when content changes.
- Custom event emission: Fires activation and deactivation events for integration with other components.
- Clean lifecycle management: Simple setup and destroy methods for proper resource cleanup.
Use cases:
- Documentation Sites: For any project with long documentation, a scroll-aware table of contents is almost a necessity. It helps users track their progress and jump between sections. This library handles the complex part of tracking the active section.
- Single-Page Landing Pages: On landing pages with multiple sections (e.g., Features, Pricing, Contact), a scrollspy navigation bar improves the user experience by showing exactly which section is in the viewport.
- Long-Form Blog Posts: We’ve used it for in-depth articles that have a floating TOC. It helps readers understand the structure of the post and see how far they’ve progressed.
- Client-Side Rendered Apps: With its
observeandfragmentAttributeoptions, the library works well in SPAs (Single Page Applications) where content is loaded dynamically and routes might contain full URLs with fragments.
How to use it:
1. Install the scrollspy library.
# NPM $ npm install @fsegurai/scrollspy
2. Import it into your project.
<script type="module" src="path/to/scrollspy.esm.js"></script>
// OR <script type="module"> import scrollspy from './src/index.js'; </script>
3. Create a TOC for your long content. Note that the href of an <a> tag must match the id of a content element.
<nav id="toc" class="toc-nav">
<ul>
<li><a href="#what-is-html">What Is HTML</a></li>
<li><a href="#what-is-css">What Is CSS</a></li>
<li>
<a href="#what-is-javascript">What Is JavaScript</a>
<ul>
<li><a href="#react">React</a></li>
<li><a href="#next-js">Next.js</a></li>
</ul>
</li>
</ul>
</nav><h2 id="what-is-html">What Is HTML</h2> <p>...</p> <hr> <h2 id="what-is-css">What Is CSS</h2> <p>...</p> <hr> <h2 id="what-is-javascript">What Is JavaScript</h2> <p>...</p> <hr> <h3 id="react">React</h3> <p>...</p> <hr> <h3 id="next-js">Next.js</h3> <p>...</p>
4. The library doesn’t come with any CSS. It only toggles classes. You define what “active” looks like. The default active class is .active, and for nested parents, it’s .parent-active.
.toc-nav {
position: fixed;
top: 120px;
right: 20px;
width: 270px;
max-height: calc(100vh - 160px);
overflow-y: auto;
background: var(--md-sys-color-surface-container);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--md-sys-color-outline-variant);
z-index: 100;
.active-parent {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
& > a {
color: var(--md-sys-color-secondary, #625b71);
font-weight: 500;
}
}
.active {
color: var(--md-sys-color-primary, #6750a4);
font-weight: 600;
background-color: var(--md-sys-color-primary-container, rgba(103, 80, 164, 0.1));
border-radius: 4px;
padding: 4px 8px;
margin: -4px -8px;
}
.toc-header {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
}
ul {
list-style: none;
margin: 0;
padding: 0;
&.nested {
margin-left: 16px;
}
li {
margin: 0;
a {
display: block;
padding: 6px 12px;
text-decoration: none;
color: var(--md-sys-color-on-surface-variant);
font-size: 13px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface);
}
&.active {
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font-weight: 500;
}
}
}
}
a {
transition: all 0.2s ease;
display: block;
padding: 4px 8px;
margin: -4px -8px;
border-radius: 4px;
&:hover {
background-color: var(--md-sys-color-surface-variant, rgba(103, 80, 164, 0.05));
}
}
}5. Instantiate the library by pointing it to your navigation container.
const spy = new scrollspy('#toc', {
// options here
});6. All available options.
nav(string): (Required) This is the CSS selector for your navigation container, like'#toc'. It tells the library where to find the navigation links.content(string): A selector for the content sections the navigation links point to. It defaults to[data-gumshoe], but you rarely need to change this since the library primarily uses thehrefattributes from your nav links to find content IDs.nested(boolean): Set this totrueif you have a nested table of contents (e.g., a<ul>inside an<li>). It enables the feature that adds a class to parent list items. Defaults tofalse.nestedClass(string): The CSS class applied to a parent<li>when one of its children is active. This only works ifnestedistrue. Defaults to'active-parent'.offset(number | () => number): A crucial option for sites with a fixed or sticky header. This value, in pixels, is subtracted from the scroll position to ensure the correct section is highlighted. You can use a number for a fixed-height header or a function for a dynamic-height header. Defaults to0.bottomThreshold(number): The distance in pixels from the bottom of the page at which the last navigation item should be automatically activated. This helps ensure the last item gets highlighted even if the content section is too short to reach the top of the viewport. Defaults to100.reflow(boolean): Iftrue, the library will recalculate the positions of all content sections when the browser window is resized. This is useful for responsive layouts where content dimensions might change. Defaults tofalse.events(boolean): Whentrue, the library fires customgumshoeactivateandgumshoedeactivateevents on thedocument, which you can listen for to trigger other actions. Defaults totrue.observe(boolean): Set totrueto automatically watch for DOM changes using aMutationObserver. This is the easiest way to handle dynamically loaded content, as the library will refresh itself when new sections are added or removed. Defaults tofalse.fragmentAttribute(string | (item: Element) => string | null): This option provides an alternative to using thehrefattribute for mapping nav links to content. You can specify a custom attribute name (e.g.,'data-scroll-target') or a function to extract the fragment identifier. This is particularly useful in SPAs (like Angular or React) wherehrefattributes might contain full routes. Defaults tonull.navItemSelector(string): A CSS selector to specify which elements inside yournavcontainer should be treated as navigation items. Defaults to'a[href*="#"]', which targets all anchor tags with a hash in theirhref.
const spy = new scrollspy('#toc', {
nav: selector,
content: '[data-gumshoe]',
nested: false,
nestedClass: 'active-parent',
offset: 0,
bottomThreshold: 100,
reflow: false,
events: true,
observe: false, // * watch for dynamic changes
fragmentAttribute: null,
navItemSelector: 'a[href*="#"]',
});7. API Methods:
spy.setup(): Re-initializes the instance.spy.detect(): Manually triggers detection based on the current scroll position.spy.refresh(): Rebuilds the map of navigation and content elements. Call this after you’ve dynamically added or removed content ifobserveisfalse.spy.destroy(): Removes all event listeners and cleans up.
8. Events:
document.addEventListener('gumshoeactivate', (event) => {
console.log('Section activated:', event.detail.target.id);
console.log('Navigation item:', event.detail.nav);
});
document.addEventListener('gumshoedeactivate', (event) => {
console.log('Section deactivated:', event.detail.target.id);
});FAQs
Q: Does it work with a sticky header that changes height?
A: Yes. The offset option can accept a function. You can write a function that calculates and returns the current height of your header, and the library will use that value during its checks.
Q: My content is loaded via AJAX. How do I make scrollspy aware of it?
A: You have two main options. The easiest is to set observe: true during initialization. The MutationObserver will detect when new elements are added to the DOM and automatically refresh the instance. Alternatively, you can manually call spy.refresh() in the callback of your AJAX request after the new content is in place.
Q: I’m using a framework like Angular or Vue and my links are full paths like /page#section. How can I make this work?
A: This is the perfect use case for the fragmentAttribute option. Instead of using href, add a custom data attribute to your links, like data-scroll-target="#section". Then, initialize the library with fragmentAttribute: 'data-scroll-target'. The library will use this attribute for mapping, leaving your href attributes intact for routing.
Q: What happens if content sections don’t have matching navigation items?
A: The library only tracks sections that have corresponding navigation links. Sections without matching navigation items are ignored during scroll detection.
Changelog:
v1.0.3 (11/20/2025)
- Bugfixes







