Scroll Progress Journey Trail — Free Sequential Waypoint Reveal

Scroll Progress Journey Trail · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Center-anchored trigger
rootMargin shrinks the observer to the viewport's exact middle.
Continuous line fill
Fill height is a real scroll-position percentage, not stepped.
Independent dual signals
Discrete lighting and continuous fill computed separately.
Sticky sidebar rail
CSS position: sticky keeps waypoints visible while reading.
IntersectionObserver lighting
Cheap, precise per-step visibility detection.
rAF-throttled fill calc
One recalculation per frame regardless of scroll event rate.
Data-target matching
Waypoints link to steps by a simple attribute, easy to extend.
No dependencies
Pure vanilla JS, IntersectionObserver, and scroll math.

About this UI Snippet

Scroll Progress Journey Trail — Waypoints That Light Up as You Read

Screenshot of the Scroll Progress Journey Trail snippet rendered live

A sidebar trail of waypoint dots tracks progress through a multi-step narrative — sign up, import data, automate, scale — lighting each dot the first time its matching section crosses the viewport's center, while a connecting line behind the dots fills smoothly and continuously rather than jumping in discrete steps. It's built entirely in vanilla JavaScript: an IntersectionObserver with a center-anchored rootMargin handles the discrete waypoint lighting, and a separate scroll-position calculation drives the continuous line fill.

Center-anchored IntersectionObserver, not a default one

The observer uses rootMargin: '-50% 0px -50% 0px', which shrinks the observer's effective viewport to a single horizontal line at 50% height — so isIntersecting only flips true the instant a step's element crosses the exact vertical center of the screen, not whenever any part of it becomes visible. That precise center-crossing is what makes each waypoint light up in a way that feels tied to "you're now reading this section," rather than lighting up too early as the section first appears at the bottom of the viewport.

Two independently-computed signals, one visual trail

The waypoint dots (discrete, permanent once lit) and the connecting line fill (continuous, reversible) are calculated by two separate pieces of logic that happen to render into the same sidebar. The line fill reads the combined bounding-box height of the entire step sequence and computes what fraction of that total height has been scrolled past the viewport's center — a single continuous percentage applied as the fill element's height, independent of which waypoints have individually lit up.

Why this differs from a stepped timeline

A common pattern nudges a fixed indicator to each step's position only when that step becomes "nearest" to center, producing a trail that jumps between fixed stop points. This snippet's line fill instead recalculates from raw scroll position on every scroll event, so it visibly, continuously catches up while scrolling between two waypoints rather than staying pinned at the last lit stop until the next one is reached.

Sticky rail, scrolling content

The .jt-rail sidebar uses position: sticky so it stays in view alongside whichever step is currently being read, while .jt-main's sections scroll normally — a standard two-column sticky-sidebar layout, with all the position math and lighting logic layered on top in JavaScript.

Customizing it

Swap the numbered dots for small avatar photos or icons, add a fifth waypoint, or make the line fill snap to the nearest lit waypoint instead of tracking exact scroll position continuously. Pair it with scroll timeline dots for a GSAP-driven alternative, or a scroll progress bar for a simpler top-of-page indicator.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's JS into an AI coding assistant like Claude and ask it to explain how rootMargin: '-50% 0px -50% 0px' changes an IntersectionObserver's behavior from "fires when any part of the target is visible" to "fires only when the target crosses the exact vertical center of the viewport" — the negative percentages shrink the observer's root area from both top and bottom simultaneously. It's also worth asking why the connecting line's fill is computed as an entirely separate scroll-position percentage rather than derived from which waypoints are lit, since that separation is what makes the line feel continuous while the dots stay discrete. Good follow-ups: ask it to make waypoints dim again when scrolled back above their step (currently they stay lit once reached), or to derive both the waypoints and steps from one shared array of step data so they can never drift out of sync when a step is added.

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 "scroll progress journey trail" effect in plain HTML, CSS, and vanilla JavaScript (no libraries).

Requirements:
- A two-column layout: a sticky left sidebar rail (position: sticky) containing a vertical connecting line and several waypoint dot elements with labels, and a right-hand main column containing full-height narrative sections, one per waypoint, each with a matching id referenced by a data-target attribute on its waypoint.
- Use a single IntersectionObserver (not a scroll-position threshold comparison) configured with rootMargin set to shrink its effective intersection area to a thin line at the exact vertical center of the viewport (e.g. '-50% 0px -50% 0px'), observing each narrative section. When a section's isIntersecting becomes true (meaning it has crossed the viewport's center), add a "lit" class to its matching waypoint dot and label.
- Separately (not derived from the waypoint lighting), calculate a continuous line-fill percentage on scroll: measure the combined bounding height of the entire sequence of narrative sections, determine what fraction of that total height has been scrolled past relative to the viewport's vertical center, clamp it to 0-1, and set a fill element's height to that percentage. This must update continuously as a smooth percentage, not jump directly between waypoint stop-points.
- Gate the scroll-based fill calculation with a requestAnimationFrame flag so it runs at most once per frame.
- Confirm the two signals behave distinctly: the waypoint dots should light up in sequence as their exact matching section crosses the viewport's center, while the connecting line's fill should visibly continue to grow smoothly even while scrolling through the middle of a section between two waypoints, not just snap at each lit dot.

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.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSAn intro, a sticky waypoint rail, four step sections, and an outro render.
  2. 2
    Scroll down the stepsEach waypoint dot lights up as its matching step crosses center.
  3. 3
    Watch the connecting lineIt fills continuously, catching up smoothly between waypoints.
  4. 4
    Scroll back upThe line un-fills exactly in reverse, tracking scroll position.
  5. 5
    Add a waypointAdd a .jt-waypoint, a matching .jt-step, and update data-target.
  6. 6
    Adjust the trigger lineChange the IntersectionObserver's rootMargin percentages.

Real-world uses

Common Use Cases

Product onboarding pages
Show progress through a multi-step setup narrative.
Long-form case studies
Track which phase of a project story the reader is in.
Course or tutorial pages
Mark lesson progress as the page scrolls.
Company history pages
Pair with scroll timeline dots for contrast.
Fundraising journeys
Show a donor how far a campaign narrative has progressed.
Documentation walkthroughs
Combine with a scroll progress bar.

Got questions?

Frequently Asked Questions

Each step section is observed by a single IntersectionObserver configured with rootMargin: '-50% 0px -50% 0px', which shrinks the observer's effective intersection area to a single line at the vertical center of the viewport. isIntersecting only becomes true the instant a step's element crosses that exact center line, so the waypoint lights up precisely when the reader is centered on that section, not as soon as it first appears at the bottom.

The line fill is calculated separately from the waypoint lighting: on every scroll event it reads the combined height of the entire step sequence and computes what fraction of that height has been scrolled past the viewport's center, applying that fraction directly as the fill element's height percentage. Because it's a raw scroll-position ratio recalculated continuously, it visibly catches up while scrolling between two waypoints rather than staying pinned at the last lit stop.

No — they're intentionally two independent signals rendered into the same sidebar. The dots are discrete and driven by IntersectionObserver center-crossing events (each one only changes state once, when its step crosses center). The line fill is continuous and driven by a scroll-position percentage recalculated every scroll event. This separation is what lets the line feel fluid while the waypoints still feel like clear, discrete checkpoints.

The line fill does, exactly and continuously, since it's recomputed fresh from the current scroll position on every event with no accumulated state. The waypoint dots as written stay lit once reached (a deliberate choice, similar to a completed-steps indicator) — if you want dots to also dim when scrolled back above their step, remove the isIntersecting-only branch and also handle the false case in the observer callback.

Render the rail and step sections with refs (or map from a shared array of step data so waypoints and sections can't drift out of sync), then in a mount effect create the IntersectionObserver with the same center rootMargin and attach the rAF-gated scroll listener for the fill calculation. Return a cleanup that disconnects the observer and removes the scroll listener.