ScrollOut Progress Nav Dots — Scroll-Synced Navigation Snippet

ScrollOut Progress Nav Dots · Navigation · Plain HTML, CSS & JS · Live preview

What's included

Features

No manual IntersectionObserver
ScrollOut wraps the observer internally and exposes onShown/onHidden directly.
Threshold-based activation
threshold: 0.5 avoids flipping the active dot on a barely-visible section edge.
Selector-based targets
The targets option points ScrollOut at any CSS selector, not just [data-scroll].
Self-correcting active state
onShown alone is enough because setActive clears every other dot first.
Click-to-scroll dots
Each dot smooth-scrolls to its section and the active state follows automatically.
Hover labels
A small label slides into view on dot hover to name the section without cluttering the rail.
Sensible initial state
The first dot is activated on load rather than waiting for the first scroll event.
Fixed positioning
The rail stays pinned to the viewport edge independent of page scroll position.

About this UI Snippet

ScrollOut Progress Nav Dots — Section Tracking Without Hand-Rolled Observers

Screenshot of the ScrollOut Progress Nav Dots snippet rendered live

A "which section is the user looking at" nav is one of the most common places developers reach for IntersectionObserver directly — set up an observer, give it a threshold, track a Set of currently-intersecting entries, and diff it every callback. ScrollOut exists specifically to remove that boilerplate: it wraps the observer internally and gives you two callbacks, onShown and onHidden, that already tell you exactly which element crossed the line.

The configuration that does the tracking

js ScrollOut({ targets: '.spd-section', threshold: 0.5, onShown: function (el) { setActive(el.id); } });

targets overrides ScrollOut's default selector ([data-scroll]) with an explicit .spd-section class, so every section in the page is tracked without needing a data-scroll attribute on each one (this snippet still adds data-scroll to the sections for clarity and to match ScrollOut's documented default pattern, but targets is what actually drives the selection here). threshold: 0.5 tells ScrollOut a section only counts as "shown" once at least half of it is inside the viewport — a deliberate choice for a nav-dot use case, because a lower threshold like 0.1 would flip the active dot the instant a section's top edge barely peeks into view, well before it's realistically the section the user is reading.

Why onShown alone is enough here

ScrollOut also exposes onHidden for elements leaving view, but this snippet doesn't use it. Because setActive() clears every other dot's .is-active class before applying it to the new one, there's no need to explicitly "turn off" the previous section's dot when it hides — the next section's onShown firing already handles that as a side effect of how setActive is written. This is a common simplification once you notice onShown/onHidden don't have to be handled symmetrically; use only the one your state model actually needs.

Click-to-scroll and the loop-back problem

Clicking a dot calls scrollIntoView({ behavior: 'smooth', block: 'center' }) on the target section. Because that scroll itself moves sections through the viewport, ScrollOut's own visibility tracking naturally fires onShown again once the destination section crosses the 0.5 threshold mid-scroll — so the active dot updates itself for free as the smooth scroll lands, with no manual "which dot did I click" bookkeeping required in the click handler.

Initial state

setActive('s1') runs once at load, outside of any ScrollOut callback. Without it, the rail would show zero active dots until the user's first scroll crosses a 0.5 threshold, which looks broken on a page that loads scrolled to the top with the first section already fully visible.

Build with AI

Build, Understand, Optimize, and Extend It With AI

This snippet is a good one to have an AI walk through the division of labor between ScrollOut's tracking and your own state logic. Paste it into an assistant like Claude and ask exactly what onShown receives as its el argument and how threshold changes when it fires relative to a section's scroll position. Then ask why this code gets away with using only onShown and never onHidden — the answer should point at how setActive() clears every dot before activating one, making onHidden redundant for this particular state model. To extend it: ask for a version that also updates browser history (history.replaceState) with the active section's hash as it changes, a version that shows scroll progress within the active section (not just which section is active) using ScrollOut's cssProps and --visible-y variable, or one that handles a page where sections have very different heights without the short ones feeling like they flash past.

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:

text
Build a fixed side-rail navigation of dot indicators using ScrollOut (v2, from a CDN) in plain HTML, CSS, and JavaScript.

Requirements:
- A page with 5 full-height sections, each with a heading and short paragraph, and a fixed vertical rail of 5 small circular dot buttons pinned to the right edge of the viewport, one per section.
- Each dot has a data-target attribute matching a section's id. Initialize tracking with ScrollOut({ targets: '.section-class', threshold: 0.5, onShown: function(el) { ... } }) and inside onShown, read el.id and mark the dot whose data-target matches it as active (an is-active class), clearing that class from every other dot first.
- Do not use onHidden — rely on onShown alone clearing prior state, and explain in a comment why that's sufficient here.
- Clicking a dot calls scrollIntoView({ behavior: 'smooth', block: 'center' }) on its matching section, and the active dot should update itself automatically via ScrollOut's own tracking as the smooth scroll lands — no manual state-setting inside the click handler beyond triggering the scroll.
- Manually activate the first dot once on script load (outside any ScrollOut callback), since ScrollOut only fires in response to scroll/visibility changes and the page loads already showing the first section.
- Give each dot a small hover label showing the section name, and style the whole thing as a minimal dark-themed rail with a glowing active state.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

Requires
<nav class="spd-rail" id="spdRail">
  <button class="spd-dot" data-target="s1" aria-label="Intro"><span class="spd-label">Intro</span></button>
  <button class="spd-dot" data-target="s2" aria-label="Approach"><span class="spd-label">Approach</span></button>
  <button class="spd-dot" data-target="s3" aria-label="Details"><span class="spd-label">Details</span></button>
  <button class="spd-dot" data-target="s4" aria-label="Results"><span class="spd-label">Results</span></button>
  <button class="spd-dot" data-target="s5" aria-label="Contact"><span class="spd-label">Contact</span></button>
</nav>
<main class="spd-main">
  <section class="spd-section" id="s1" data-scroll>
    <span class="spd-tag">01 · Intro</span>
    <h2>Scroll-tracked navigation</h2>
    <p>ScrollOut's onShown callback tells this page which section is currently visible, and the matching dot lights up.</p>
  </section>
  <section class="spd-section" id="s2" data-scroll>
    <span class="spd-tag">02 · Approach</span>
    <h2>One callback, five dots</h2>
    <p>No IntersectionObserver boilerplate here — ScrollOut wraps it and exposes onShown/onHidden directly.</p>
  </section>
  <section class="spd-section" id="s3" data-scroll>
    <span class="spd-tag">03 · Details</span>
    <h2>Click a dot to jump</h2>
    <p>Each dot smooth-scrolls to its section, and ScrollOut picks up the new active section once the scroll settles.</p>
  </section>
  <section class="spd-section" id="s4" data-scroll>
    <span class="spd-tag">04 · Results</span>
    <h2>Works both directions</h2>
    <p>Scrolling up deactivates a section's dot the same way scrolling down activates the next one.</p>
  </section>
  <section class="spd-section" id="s5" data-scroll>
    <span class="spd-tag">05 · Contact</span>
    <h2>End of the page</h2>
    <p>Five sections, five dots, one small ScrollOut config.</p>
  </section>
</main>

Step by step

How to Use

  1. 1
    Add the ScrollOut CDN scriptOne script tag, no CSS required — ScrollOut has no stylesheet of its own.
  2. 2
    Mark each section as a targetGive sections a shared class or data-scroll attribute so ScrollOut's targets option can find them.
  3. 3
    Call ScrollOut with onShownPass threshold: 0.5 and an onShown callback that reads el.id and updates the active dot.
  4. 4
    Match dots to sections by data-targetEach dot button carries a data-target matching a section id.
  5. 5
    Wire click-to-scrollscrollIntoView({ behavior: "smooth" }) on the matching section for each dot.
  6. 6
    Set the initial active dot manuallyCall setActive() once on load since ScrollOut only fires on scroll/visibility change.

Real-world uses

Common Use Cases

Long-form landing pages
Give visitors a persistent sense of progress through a scrollytelling page.
Documentation sidebars
Track which doc section is in view the same way a table-of-contents highlight does.
Portfolio case studies
A minimal dot rail that doubles as both navigation and a progress indicator.
Teaching ScrollOut basics
A compact example of targets, threshold, and onShown before more advanced cssProps usage.

Got questions?

Frequently Asked Questions

ScrollOut tracks every element matched by the targets option using an internal IntersectionObserver-based visibility check, and calls onShown(el) whenever one crosses the configured threshold going into view. This snippet reads el.id inside onShown and toggles the matching dot's active class.

It sets how much of a target element must be visible before ScrollOut considers it "shown" — 0.5 means at least half the section must be in the viewport. A lower value would activate a dot as soon as a sliver of the next section appears, which reads as premature.

Because setActive() removes the active class from every dot before adding it to the new one, there's no separate bookkeeping needed when a section leaves view — the next onShown call already clears the previous state as a side effect.

No — scrollIntoView triggers a real scroll, and ScrollOut's visibility tracking picks up the destination section crossing the threshold mid-scroll just like any user-driven scroll, so the active dot updates itself without special-casing the click.

ScrollOut only fires callbacks in response to scroll or visibility changes. On initial page load, before any scroll event happens, no dot would be marked active without this manual call — even though the first section is already fully visible.

Yes — ScrollOut's targets option accepts any valid CSS selector string, or a NodeList/element array. It defaults to [data-scroll] if omitted, which is why this snippet's sections still carry that attribute for documentation clarity even though targets overrides it.