More Scroll Snippets
Scroll-Spy Navigation — Free HTML CSS JS Snippet
Scroll-Spy Navigation · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
scroll event handler, so there is no per-frame geometry math running on the main thread.content panel (not the viewport), so the spy logic works correctly inside nested scroll containers, cards, or split-pane layouts.indicator element glides between links using translateY() and a cubic-bezier transition, producing a cohesive "alive" feel rather than crossfading separate backgroundsscrollTo({ behavior: "smooth" }) calls re-trigger the same observer that handles manual scrolling, so there is no separate "set active on click" code path to maintainrootMargin: "0px 0px -65% 0px") makes a section "active" the moment it reaches the spot a reader is actually looking at, instead of whichever section happens to cover the most total areatarget.offsetTop - content.offsetTop correctly computes scroll position inside a custom scrollable region, a detail that trips up many naive smooth-scroll implementationsAbout this UI Snippet
How this scroll-spy navigation was built — IntersectionObserver, a translateY indicator, and synced smooth-scroll

This snippet recreates the "active section auto-highlights in the sidebar as you scroll" navigation found in documentation sites, long-form landing pages, and dashboard layouts — a sliding pill glides between links to track whichever section is currently in view, and clicking a link smooth-scrolls the content panel to match. It's built with the native IntersectionObserver API, CSS transforms, and roughly 35 lines of JavaScript — no scroll-event listeners or manual position math required.
Why IntersectionObserver instead of a scroll listener
The classic way to build scroll-spy navigation is to attach a scroll event handler and manually compare each section's getBoundingClientRect() against the viewport on every single scroll tick — a pattern notorious for janky performance because it runs dozens of times per second on the main thread. This snippet instead creates an IntersectionObserver scoped to the scrollable .content panel via the root option, with a rootMargin: '0px 0px -65% 0px' that shrinks the observer's effective viewport down to a thin band hugging the top of the panel. A section is reported as intersecting — and becomes "active" — the instant it crosses *that* band, which is what a reader's eyes are actually resting on, rather than whichever section happens to cover the most total area. The browser handles all the geometry calculations off the main thread and notifies the page only when something meaningfully changes — the same efficient "let the browser tell you when it matters" approach used by the Scroll Pin Story snippet for its scroll-driven panel transitions.
A sliding indicator driven by one CSS transform
Rather than animating each link's background individually, a single absolutely-positioned .indicator pill sits behind all the links (z-index: 0) and glides between them via transform: translateY(link.offsetTop). Every time setActive(id) runs, it toggles the .active class (which switches the link's text to white so it reads clearly over the pill) and repositions the indicator to the newly active link's vertical offset — a CSS cubic-bezier transition handles the actual gliding motion. This "one shared element that moves to match state" technique is the same one driving the sliding selector in many tab bars and segmented controls; it produces a much more cohesive, "alive" feel than crossfading individual backgrounds.
Keeping clicks and scroll position in sync
Clicking a sidebar link calls e.preventDefault() (so the browser's native anchor-jump doesn't fight the custom scroll), then computes the target section's offset *relative to the scroll container* — target.offsetTop - content.offsetTop — and calls content.scrollTo({ top, behavior: 'smooth' }). Because the observer is also scoped to that same .content element via its root option, the programmatic scroll naturally re-triggers the intersection callback as the section comes into view, keeping the active link and indicator in sync whether the visitor scrolls manually or clicks a link — there is no separate "set active on click" branch to maintain.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to work out the observer geometry by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly what the rootMargin value of "0px 0px -65% 0px" does to the observer's effective viewport, or why root: content rather than the default (the browser viewport) is required here. The same assistant can help optimize it — asking whether the indicator's translateY-based positioning would still work correctly if the sidebar links wrapped onto multiple lines, or whether the observer callback should debounce rapid successive intersections during a fast programmatic scroll. It's also useful for extending the effect: ask it to support nested sub-sections with a secondary indicator, add keyboard arrow-key navigation between links, or make the indicator resize its height to match variable-height links instead of a fixed 36px. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll-spy navigation" sidebar in plain HTML, CSS, and JavaScript using only the native IntersectionObserver API — no scroll event listener, no scroll-spy library.
Requirements:
- A sidebar navigation containing several links, each carrying a data-target attribute matching a section's id, plus a single absolutely-positioned indicator pill sitting behind the links at a lower z-index.
- A separate scrollable content container (not the page/window) holding several full-height sections, each with a matching id.
- Write a setActive(id) function that loops through the links, toggles an active class based on whether each link's data-target matches the given id, and on the matching link repositions the indicator using a CSS transform (translateY set to that link's offsetTop) rather than changing top/margin — with a CSS transition on transform providing the glide animation.
- Create an IntersectionObserver scoped to the scrollable content container via its root option (not the default browser viewport), with a rootMargin that shrinks the effective observation area to a thin horizontal band positioned in the upper portion of the container (e.g. by pushing the bottom edge far up with a large negative percentage), and observe every section with it.
- Inside the observer's callback, call setActive with the id of any section reported as intersecting.
- Add a click handler to each link that calls preventDefault (so the browser's native anchor jump does not fight the custom scroll), then calls the content container's scrollTo with smooth behavior, scrolling to the target section's offsetTop minus the content container's own offsetTop (not the target's offsetTop alone, which would be relative to the wrong ancestor).
- Confirm both manual scrolling and clicking a link keep the indicator and active link in sync, since both paths trigger the same observer.Step by step
How to Use
- 1Build a sidebar of links plus a free-floating indicatorAdd
<a class="spy-link" data-target="sectionId">links inside aposition: relativenav, and one absolutely-positioned<span class="indicator">sibling that will slide behind whichever link is active. - 2Style the indicator to glide with a transform transitionGive
.indicatoratransition: transform 0.32s cubic-bezier(...). Moving it withtransform: translateY()rather thantopkeeps the animation smooth and off the layout-recalculation path. - 3Write a setActive(id) that toggles classes and repositions the pillLoop the links, toggle
.activebased on whetherlink.dataset.target === id, and on the matching link setindicator.style.transform = "translateY(" + link.offsetTop + "px)"so the pill glides to its position. - 4Observe sections with a scoped IntersectionObserverCreate
new IntersectionObserver(callback, { root: contentEl, rootMargin: "0px 0px -65% 0px", threshold: 0 }), call.observe()on every section, and inside the callback callsetActive(entry.target.id)wheneverentry.isIntersectingis true. - 5Smooth-scroll to a section on link clickPrevent the link's default jump with
e.preventDefault(), find the target section, and callcontent.scrollTo({ top: target.offsetTop - content.offsetTop, behavior: "smooth" })so the scroll stays inside the custom container. - 6Set an initial active stateCall
setActive("overview")(or whichever section should be highlighted by default) once on load, so the indicator is correctly positioned before any scrolling or clicking happens.
Real-world uses
Common Use Cases
root and rootMargin — the same configuration options used for lazy-loading images, infinite scroll, and scroll-triggered animations..indicator pill repositioned via transform is the same technique behind animated tab bars, segmented controls, and breadcrumb highlights — copy the pattern any time one element needs to "follow" the active item in a list.Got questions?
Frequently Asked Questions
A scroll event fires many times per second and forces you to manually call getBoundingClientRect() on every section to figure out what is visible — expensive geometry work running repeatedly on the main thread, which is the classic cause of janky scroll-spy implementations. IntersectionObserver instead lets the browser track visibility natively and only calls your callback when a watched element actually crosses a visibility threshold, which is dramatically cheaper and smoother, especially with many sections.
setActive(id) finds the link whose data-target matches the newly active section's id, and sets indicator.style.transform = "translateY(" + link.offsetTop + "px)". Because the indicator and links share the same parent and the indicator is absolutely positioned, the link's offsetTop is exactly the vertical distance the pill needs to travel — and the CSS transition on transform animates that move smoothly.
By default, an IntersectionObserver measures visibility against the browser viewport — but here, the scrollable area is a .content div *inside* the page, not the page itself. Passing root: content tells the observer to measure intersection relative to that container's bounds instead, so the spy logic correctly reflects what is visible inside the scrollable panel, not what happens to be visible on the user's screen.
Without it, the browser's native anchor-jump behavior (href="#sectionId") would instantly snap the *whole page* to that element, fighting with — or completely overriding — the custom content.scrollTo({ behavior: "smooth" }) call that scrolls just the inner container smoothly. Preventing the default lets the snippet fully control which element scrolls and how.
target.offsetTop gives a section's position relative to its nearest positioned ancestor — which may not be the scroll container itself. Subtracting content.offsetTop converts that into a position *relative to the scrollable .content element*, which is what content.scrollTo({ top }) actually expects. Skipping this subtraction is a common bug that causes smooth-scroll to land slightly above or below the intended section.
Yes — copy the HTML, CSS, and JS with the buttons on this page and use them anywhere, including commercial products, with no attribution required. It relies only on the native IntersectionObserver API, CSS transforms/transitions, and vanilla JavaScript — no scroll-spy library or licensing to track.