← Scroll

Scroll Path Travel

A paper plane flies along a winding route as you scroll, banking round the curves and leaving a trail, and each stop lights up as it passes. Scroll back and it flies home again.

Scroll inside ↓

Plan Design Build Launch

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-path-travel to a container holding an <svg> with the route as a <path data-path>.
  3. Add the traveller inside the SVG as <g data-traveller>, drawn pointing right around 0,0 (or use any HTML element, like an image).
  4. Optionally add stops as <g data-at="0.5"> (0 is the start, 1 the end). They’re placed on the route and get the class sptv-on once the traveller passes.

Options

Add any of these attributes to the same element.

data-startWhere in the view the container’s top starts the trip, from 0 (top) to 1 (bottom). Default 0.8.
data-endWhere in the view the container’s bottom ends the trip. Default 0.3.
data-rotate="false"Keep the traveller upright instead of turning with the route.
data-trailThe trail’s colour. Default the SVG’s text colour; false for no trail.
data-smoothHow quickly it catches up with the scroll, from 0 to 1. Default 0.14; 1 follows it exactly.

Works inside scrolling boxes too: it follows the nearest scrolling parent.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scroll Path Travel: something, like a paper plane, travels along a
    // winding SVG path as you scroll, turning to follow the curves and leaving
    // a trail behind it. Scroll back and it flies back. Stops along the way
    // light up as it passes them.
    // Usage: <div data-scroll-path-travel>
    //          <svg viewBox="0 0 300 600">
    //            <path data-path d="…" fill="none" stroke="#ddd"/>
    //            <g data-traveller>…</g>        (drawn pointing right, centred on 0,0)
    //            <g data-at="0.5">…</g>         (optional stops, drawn round 0,0:
    //                                            placed halfway along, lit once passed)
    //          </svg>
    //        </div>
    // The traveller can also be an HTML element (an <img>, an emoji) anywhere
    // inside the container; it is moved to sit on the path.
    // Optional: data-start / data-end (where in the view the trip starts and
    // ends: the trip starts when the container's top reaches data-start and
    // ends when its bottom reaches data-end, 0 = top, 1 = bottom of the view;
    // defaults 0.8 and 0.3), data-rotate="false" (keep the traveller upright),
    // data-trail (the trail's colour, default the SVG's text colour;
    // "false" for no trail), data-smooth (how quickly it catches up
    // with the scroll, 0 to 1, default 0.14; 1 follows it exactly).
    // Stops with data-at (0 to 1) get the class sptv-on once it passes them.
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { start: 0.8, end: 0.3, smooth: 0.14 };

    var css =
      '.sptv-trail{fill:none!important;pointer-events:none}' +
      '.sptv-html{position:absolute;left:0;top:0;pointer-events:none;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function setup(el) {
      var d = el.dataset;
      var startAt = num(d.start, DEFAULTS.start);
      var endAt = num(d.end, DEFAULTS.end);
      var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
      var turn = d.rotate !== 'false';
      var svg = el.querySelector('svg');
      var path = el.querySelector('[data-path]') || (svg && svg.querySelector('path'));
      var traveller = el.querySelector('[data-traveller]');
      if (!svg || !path || !traveller) return;
      var inSvg = traveller instanceof SVGElement;
      var stops = Array.prototype.slice.call(el.querySelectorAll('[data-at]'));
      var box = scroller(el);
      if (reduced()) smooth = 1;

      var len = path.getTotalLength();

      // SVG stops sit on the path at their data-at.
      stops.forEach(function (s) {
        if (!(s instanceof SVGElement)) return;
        var sp = path.getPointAtLength(Math.max(0, Math.min(1, num(s.getAttribute('data-at'), 0))) * len);
        s.setAttribute('transform', 'translate(' + sp.x.toFixed(2) + ' ' + sp.y.toFixed(2) + ')');
      });

      // A copy of the path, drawn over it only as far as the traveller has got.
      var trail = null;
      if (d.trail !== 'false') {
        trail = path.cloneNode(false);
        trail.removeAttribute('data-path');
        trail.removeAttribute('id');
        trail.classList.add('sptv-trail');
        trail.setAttribute('aria-hidden', 'true');
        var cs = getComputedStyle(path);
        trail.style.stroke = d.trail || 'currentColor';
        trail.style.strokeWidth = cs.strokeWidth;
        trail.style.strokeLinecap = 'round';
        trail.style.strokeDasharray = len + ' ' + len;
        trail.style.strokeDashoffset = len;
        path.parentNode.insertBefore(trail, path.nextSibling);
      }
      if (!inSvg) {
        if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
        traveller.classList.add('sptv-html');
      }

      var target = 0, shown = -1, running = false, visible = true;

      function progress() {
        var r = el.getBoundingClientRect();
        var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var viewH = box ? box.clientHeight : window.innerHeight;
        var from = top + viewH * startAt;         // container top here: the trip starts
        var to = top + viewH * endAt - r.height;  // container bottom here: the trip ends
        return Math.max(0, Math.min(1, (from - r.top) / (from - to || 1)));
      }

      function paint() {
        var l = shown * len;
        var pt = path.getPointAtLength(l);
        var a = 0;
        if (turn) {
          var p0 = path.getPointAtLength(Math.max(0, l - 1));
          var p1 = path.getPointAtLength(Math.min(len, l + 1));
          a = Math.atan2(p1.y - p0.y, p1.x - p0.x) * 180 / Math.PI;
        }
        if (inSvg) {
          traveller.setAttribute('transform', 'translate(' + pt.x.toFixed(2) + ' ' + pt.y.toFixed(2) + ') rotate(' + a.toFixed(2) + ')');
        } else {
          // Map the point from SVG units to the container's pixels.
          var m = path.getScreenCTM();
          var er = el.getBoundingClientRect();
          var sp = svg.createSVGPoint();
          sp.x = pt.x; sp.y = pt.y;
          sp = sp.matrixTransform(m);
          var x = sp.x - er.left - el.clientLeft - traveller.offsetWidth / 2;
          var y = sp.y - er.top - el.clientTop - traveller.offsetHeight / 2;
          traveller.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) rotate(' + a.toFixed(2) + 'deg)';
        }
        if (trail) trail.style.strokeDashoffset = (len - l).toFixed(2);
        for (var i = 0; i < stops.length; i++) {
          stops[i].classList.toggle('sptv-on', shown >= num(stops[i].getAttribute('data-at'), 2) - 0.002);
        }
      }

      function tick() {
        shown += (target - shown) * smooth;
        if (Math.abs(target - shown) < 0.0005) shown = target;
        paint();
        if (shown !== target && visible) requestAnimationFrame(tick);
        else running = false;
      }

      function onScroll() {
        target = progress();
        if (shown < 0 || !visible) { shown = target; paint(); return; }
        if (running) return;
        running = true;
        requestAnimationFrame(tick);
      }

      if ('IntersectionObserver' in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible) onScroll();
        }, { root: box }).observe(el);
      }

      onScroll();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { shown = -1; onScroll(); });
    }

    function init() {
      document.querySelectorAll('[data-scroll-path-travel]').forEach(function (el) {
        if (el._sptv) return;
        el._sptv = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scroll-path-travel data-trail="#a855f7">
  <svg viewBox="0 0 300 640">
    <path data-path d="M60 30 C60 130 240 100 240 210 S60 310 60 420"
          fill="none" stroke="#e9d5ff" stroke-width="5" stroke-dasharray="2 12"/>
    <g data-at="1"><circle r="10"/></g>
    <g data-traveller><path d="M-17 -13 L19 0 L-17 13 L-9 0Z"/></g>
  </svg>
</div>