All components

Dotted Path Traveller

GSAP animation component · Published 2026-08-02 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A sticky scene where a dot travels along a drawn dashed SVG path as you scroll, the line revealing itself behind it and lettered milestones lighting up as it passes each one. The whole geometry lives in the path's `d` and nowhere else: the traveller and every milestone are placed by asking the path itself with `getPointAtLength()` (milestones carry a `data-t` fraction, not coordinates), so changing the curve moves all five things at once and the route can never drift from its labels. Reveal is done by putting the full length as a trailing gap in `stroke-dasharray` and retracting it, which preserves the dotted pattern instead of sliding it. A ResizeObserver re-places everything when the SVG box changes without a window resize. A flatter path swaps in below 760px so the labels stop colliding.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitysection
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll scrolltrigger sticky svg path motion-path getpointatlength milestones map journey stroke-dashoffset lenis

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Dotted Path Traveller — a route with stops, drawn by the scroll

Goal

Build a pinned scene where a dot travels along a dashed line as you scroll, the line revealing itself behind it, and named milestones light up as it passes each one. Use it when there is a journey with stops and the spatial order is the information: where the materials come from, a route, a timeline that is not a straight bar.

Tech

Vanilla HTML/CSS/JS with ES modules: gsap + ScrollTrigger, and lenis. No MotionPathPlugin and no SVG animation librarygetPointAtLength() is native, exact, and free.

Wire Lenis to ScrollTrigger — this is not optional:

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);

Lenis animates its own scroll position; ScrollTrigger reads the native one. Without that first line the two clocks drift and the triggers fire at the wrong moment or not at all — the component looks broken in a way that never reaches the console. lagSmoothing(0) stops GSAP from swallowing a long frame, which on a scrubbed mechanic shows up as a jump.

The decision that holds the whole thing up

The geometry lives in the path's d and nowhere else. Neither the traveller nor the milestones carry coordinates. Milestones carry a *fraction of the route*:

<div class="stop" data-t="0.32"><b>Utiel</b><span>el vino</span></div>

and everything is placed by asking the path itself:

const pt = ahead.getPointAtLength(length * parseFloat(h.dataset.t));

Measure against the SVG's painted box, but express the result relative to .map. Those are two different boxes the moment .map has any padding — and it needs padding, to give the labels room — so a conversion that skips the offset is right only by accident:

const toScreen = (pt) => {
  const box  = svg.getBoundingClientRect();     // where the drawing actually is
  const host = map.getBoundingClientRect();     // the positioned ancestor labels live in
  return {
    x: (box.left - host.left) + (pt.x / VIEWBOX.w) * box.width,
    y: (box.top  - host.top)  + (pt.y / VIEWBOX.h) * box.height,
  };
};

The viewBox is 0 0 1000 240 and the SVG uses preserveAspectRatio="xMidYMid meet"; both numbers are load-bearing, because the ratio between viewBox units and painted pixels is what the two divisions above assume.

Change the curve and the line, the traveller and all four labels move together. The alternative — positioning labels by hand with left/top — works exactly until somebody adjusts the curve three weeks later, and then the names point at nothing. There is no second copy of the geometry to drift.

The trap: getPointAtLength returns viewBox units, not pixels

This is the bug that only shows up when you resize.

The <svg> scales with its container, so a point at x = 500 in a viewBox="0 0 1000 240" is at half the painted width — which is not 500 pixels unless the box happens to be exactly 1000 wide. Convert:

const aPantalla = (pt) => {
  const caja = svg.getBoundingClientRect();
  return { x: (pt.x / 1000) * caja.width, y: (pt.y / 420) * caja.height };
};

Writing pt.x straight into style.left works at the one width you developed on and misaligns at every other. It reads fine in code review.

Revealing a *dotted* line: use a mask, not the dash array

This is the part that goes wrong twice before it goes right.

Animating stroke-dashoffset on the dotted path slides the dots along the line instead of uncovering them, because the offset shifts the whole pattern. The next idea — stuffing the revealed length into the array as 7 9 ${length * p} ${length} — is worse and *looks* like it works: it gives you 16 units of dots followed by one long solid dash. The line silently stops being dotted, and a contact sheet is the only way you notice.

The correct technique is a mask. Three paths, all sharing the same d:

<defs>
  <mask id="reveal" maskUnits="userSpaceOnUse">
    <path id="path-mask" fill="none" stroke="#fff" stroke-width="60" stroke-linecap="butt" />
  </mask>
</defs>
<path id="path-ahead" class="ahead" stroke-dasharray="7 9" />                    <!-- route ahead, faint -->
<path id="path-done"  stroke-dasharray="7 9" mask="url(#reveal)" />           <!-- route done -->

The mask is the thing that gets drawn: one fat single dash of the full length, retracted with dashoffset.

maskPath.style.strokeDasharray  = `${length} ${length}`;
maskPath.style.strokeDashoffset = `${length * (1 - p)}`;

The dotted pattern underneath is never touched, so the dots stay put and are simply uncovered.

Keep the faint "route ahead" path visible from the start: it tells the reader where they are going, and without it the section opens on an empty screen.

Motion

One ScrollTrigger, no tween on the traveller, no scrub — positions are written directly on every update and Lenis supplies the smoothness:

const advance = (p) => {
  const pt = aPantalla(ahead.getPointAtLength(length * p));
  traveller.style.left = `${pt.x}px`;
  traveller.style.top  = `${pt.y}px`;
  maskPath.style.strokeDashoffset = `${length * (1 - p)}`;
  km.textContent = Math.round(KM_TOTAL * p);
  stops.forEach((h) => h.classList.toggle("encendido", p >= parseFloat(h.dataset.t) - 0.01));
};

ScrollTrigger.create({
  trigger: ".pista", start: "top top", end: "bottom bottom",
  onUpdate: (s) => advance(s.progress),
  onRefresh: (s) => { geometry(); placeStops(); advance(s.progress); },
  invalidateOnRefresh: true,
});

The milestone toggle is a class, so the fade is a CSS transition (0.35s) rather than a tween: scrubbing back and forth past a milestone must not queue up animations.

The - 0.01 margin stops the last milestone flickering when progress lands on 1 by rounding.

onRefresh must redo three things in this order: the path (it may have swapped at the breakpoint), then its length, then the milestones — each depends on the previous.

Placing the labels: three rules, all learned by looking

1. The offset is measured, never a constant. The label is centred with translate(-50%, -50%), so clearing the line takes half its OWN height plus a gap:

const half = stop.offsetHeight / 2;
const top = (side) => (side === "above" ? p.y - half - GAP : p.y + half + GAP);

A hardcoded -52 against a 126px label puts every label 11px on top of the line it is annotating. The number was right for the sketch and wrong for the content, and it stays wrong the moment anybody edits a caption.

2. Which side a label goes on is asked of the curve. Sample the path slightly before and after each stop: on a crest the label goes above, in a trough below.

const sideOf = (t) => {
  const step = 0.05;
  const y0 = path.getPointAtLength(len * Math.max(0, t - step)).y;
  const y1 = path.getPointAtLength(len * Math.min(1, t + step)).y;
  return path.getPointAtLength(len * t).y <= (y0 + y1) / 2 ? "above" : "below";
};

Alternating is what stops four labels crowding the same side of an S — and like everything else here it is derived from d, so it survives a change to the curve.

3. The curve's preference is only a preference. Near the ends of a path that climbs the whole frame the preferred side runs out of room and the label leaves the map. Fall back to the other side rather than hand-tuning the path:

let side = sideOf(t);
if (!fits(side)) side = side === "above" ? "below" : "above";

And give .map vertical padding — that is where the below-labels live. Vertical only: horizontal padding shrinks the SVG and squeezes the route.

Use ResizeObserver, not the resize event

The SVG box changes size without the window changing width: a font finishing loading, a scrollbar appearing, a parent container animating. resize misses all of those.

new ResizeObserver(() => { placeStops(); ScrollTrigger.refresh(); }).observe(svg);

Responsive

Below 760px, swap the path for a flatter one. A three-curve S in 390px of width stacks the labels on top of each other; there is no amount of font-size tuning that fixes it. Two paths, one setAttribute("d", …) in geometry().

Reduced motion

Draw the line complete, light every milestone, put the traveller at the end, and collapse the track to min-height: 0. The whole route is visible at once — which is the information. Do not make the reader scroll 260svh for a static picture.

Details that matter more than they look

  • Each milestone needs a leader and an anchor dot on the line (::before + ::after).

Without them the name floats and says nothing about which part of the route it belongs to.

  • transform: translate(-50%, -50%) on traveller and milestones: centres them on the point

without subtracting their own size in the maths.

  • A live counter (0 → 88 km) turns the traveller from decoration into a readout.
  • #path-ahead is a document-wide id — two instances on one page collide. Scope it if you need two.

Adapting

Change the path, the number of milestones (three to six), the traveller (a dot, an icon, a small image), the counter's unit, the palette. Keep: geometry only in d, data-t fractions instead of coordinates, the viewBox→pixel conversion, the trailing-gap reveal, the class-based milestone toggle, and the ResizeObserver.

Adapting this to React

Everything above describes a standalone document: a script that waits for DOMContentLoaded, reaches into the page with document.querySelector and document.getElementById for the svg, the three paths, the traveller, the stops and the counter, computes the geometry once, and then — unless prefers-reduced-motion short-circuits it — wires one Lenis instance, one gsap.ticker subscription that pumps it, one ScrollTrigger pinned to .track, a ResizeObserver on the <svg>, and a fonts.ready continuation, none of which it ever expects to undo. React withdraws all three of those guarantees at once — the ready-made document, the free run of an unscoped lookup, the license to run exactly once — and it does it quietly: the traveller walks the curve correctly on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. By the time that first unmount can land, this component already has a Lenis instance receiving wheel input, a ticker callback pumping it every frame, a pinned ScrollTrigger reading .track's scroll range, a ResizeObserver watching the <svg>, and possibly a fonts.ready promise still pending. A double mount that doesn't undo all of it doesn't draw a second traveller next to the first — geometry() and placeStops() re-run unconditionally at the top of the effect and overwrite the shared d, length and label positions either way — it leaves two Lenis instances fighting over the same wheel event, a ticker calling .raf() on whichever instance got destroyed first, and two ScrollTriggers both driving advance() off the same scrub. None of this reproduces in a production build, since React only double-invokes effects in development, so the teardown below is load-bearing, not defensive.

*(1) The entry point* — the whole body sits inside document.addEventListener("DOMContentLoaded", ...). A React component mounts after that event has already fired on the document, so the listener attaches and is never called back: the path never gets a d, the traveller never moves, nothing to debug. Delete the listener and move its entire body — plugin registration, the reduce check, geometry()/placeStops(), the Lenis and ticker wiring, ScrollTrigger.create, the ResizeObserver, and the fonts.ready call — into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can move to module scope; re-running it on every mount is harmless but buys nothing.

*(2) Element lookups* — most of the lookups here (.map svg, .traveller, .km, .map itself) are plain document.querySelector calls and need the usual root-ref scoping. The three that actually break under a remount are the ones using document.getElementById: path-ahead, path-done and path-mask are document-wide ids, and getElementById has no notion of which of the two subtrees present during the StrictMode remount is "this" component's — it returns whichever copy comes first in document order, so the live effect's ahead/done/maskPath variables can end up pointing at the outgoing instance's paths instead of its own. Give the root element a ref and replace every getElementById("path-x") with a scoped root.querySelector("#path-x"), which restricts the match to this instance's own subtree — the same fix the vanilla version already needs the moment a second copy of this component lands on one page (see the note above about #path-ahead colliding). gsap.utils.toArray(".stop") and the trigger: ".track" string handed to ScrollTrigger.create don't need the same rewrite: once both sit inside the gsap.context below, GSAP resolves that selector text against the context's own scope automatically.

*(3) Cleanup* — wrap the reduced-motion check, geometry()/placeStops(), the Lenis setup and the one ScrollTrigger in a gsap.context scoped to the root ref:

useEffect(() => {
  const ctx = gsap.context(() => {
    // reduce check, geometry()/placeStops(), Lenis + ticker wiring, ScrollTrigger.create
  }, rootRef);
  return () => ctx.revert();
}, []);

There are no tweens in this component — advance() writes left/top/strokeDashoffset straight onto the traveller and the mask path, and placeStops() writes straight onto each .stop — so ctx.revert()'s only job here is removing the one ScrollTrigger that calls advance() on scroll and on refresh. It does not reach gsap.ticker.add((t) => lenis.raf(t * 1000)) — a ticker subscription is neither a tween nor a trigger — and this component has no requestAnimationFrame loop of its own to fall back on: that one subscription is the entire frame pump for smooth scroll. Keep the callback by reference and tear things down in this order:

const pumpLenis = (t) => lenis.raf(t * 1000);
gsap.ticker.add(pumpLenis);
// cleanup:
gsap.ticker.remove(pumpLenis);
lenis.destroy();
ctx.revert();

Removing the ticker subscription before destroying Lenis matters here specifically: lenis.on("scroll", ScrollTrigger.update) means any frame landing between the two calls would otherwise invoke .raf() on a dead instance and then push a stale scroll value into ScrollTrigger.update(). Lenis is a document-level resource, not this section's alone — if the route map ships as one part of a larger page, lift the new Lenis() call to the app shell and have this effect call lenis.on("scroll", ScrollTrigger.update) on the instance that already exists, instead of constructing a second one that fights the first over the same wheel event.

The ResizeObserver watching the <svg> needs its own disconnect() in the same cleanup — it isn't a GSAP object, so the context doesn't know it exists, and left running it keeps a reference to the <svg> node alive, along with everything its callback closes over (placeStops, ScrollTrigger.refresh), past the unmount:

const ro = new ResizeObserver(() => { placeStops(); ScrollTrigger.refresh(); });
ro.observe(svg);
// cleanup, in addition to the above:
ro.disconnect();

Finally, document.fonts?.ready.then(() => ScrollTrigger.refresh()) is a real gap — font loading time depends on cache and network, easily long enough for a StrictMode unmount or a genuine navigation away to land inside it. ScrollTrigger.refresh() is not scoped to this component: it recalculates every trigger on the page, so a call landing after unmount runs against a layout that has already lost .track's contribution to the page's scroll height. Guard the continuation with the same cancellation flag the cleanup sets:

let cancelled = false;
document.fonts?.ready.then(() => {
  if (cancelled) return;
  ScrollTrigger.refresh();
});
return () => {
  cancelled = true;
  ro.disconnect();
  gsap.ticker.remove(pumpLenis);
  lenis.destroy();
  ctx.revert();
};

The prefers-reduced-motion branch needs none of this: it calls advance(1) and returns before Lenis, the ticker, the trigger or the observer ever get created, so on that path there is simply nothing for the cleanup above to do.