← Scroll

Scroll-drawn Path

An SVG line draws itself as you scroll, its tip keeping pace with the screen, and rubs itself out again if you scroll back. A glowing dot rides the tip and milestones pop in as the line reaches them. Made for timelines and roadmaps.

Scroll inside ↓

Idea Design Build Launch

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-drawn-path to an <svg>. Its paths, lines and circles draw as you scroll.
  3. Add data-at="0.5" to any marker or label that should pop in once the line gets that far (0 to 1).

Options

Add any of these attributes to the same element.

data-atOn a marker inside: how far along the line it appears, 0 to 1.
data-pen="false"No glowing dot on the tip.
data-pen-colorColour of the dot. Default: the line’s colour.
data-offsetWhere the tip sits in the view, from 0 (top) to 1 (bottom). Default 0.6.

Best with a line that runs down the drawing, as the tip follows the scroll from top to bottom. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scroll-drawn Path: an SVG line draws itself as you scroll, its tip keeping
    // pace with the middle of the screen, and rubs itself out again if you
    // scroll back. A glowing dot rides the tip, and markers along the way pop
    // in as the line reaches them. Made for timelines and roadmaps.
    // Usage: <svg data-scroll-drawn-path viewBox="0 0 200 600">
    //          <path d="M100 0 C 20 150, 180 300, 100 600" stroke="#a855f7" fill="none"/>
    //          <circle data-at="0.5" cx="100" cy="300" r="8"/>   (pops in halfway)
    //        </svg>
    // Every <path>, <line>, <polyline> and <circle> without data-at is drawn.
    // Optional: data-at on any element inside: the point (0–1) along the line
    // where it pops in. data-pen="false" (no dot on the tip), data-pen-color
    // (default: the line's colour), data-offset (where the tip sits in the
    // view, 0 top to 1 bottom, default 0.6).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { offset: 0.6 };

    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') && p.scrollHeight > p.clientHeight) return p;
      }
      return null;
    }

    function setup(svg) {
      var d = svg.dataset;
      var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
      var lines = Array.prototype.slice.call(svg.querySelectorAll('path, line, polyline, circle')).filter(function (n) {
        return !n.hasAttribute('data-at') && !n.closest('[data-at]');
      });
      if (!lines.length) return;
      var marks = Array.prototype.slice.call(svg.querySelectorAll('[data-at]')).map(function (n) {
        n.style.transformBox = 'fill-box';
        n.style.transformOrigin = '50% 50%';
        anime.utils.set(n, { opacity: 0, scale: 0.4 });
        return { el: n, at: +n.dataset.at || 0, on: false };
      });
      // The longest line is the one the tip follows and the markers count along.
      var main = lines[0], len = 0, lengths = [];
      function measure() {
        lengths = lines.map(function (n) { return n.getTotalLength(); });
        len = 0;
        lines.forEach(function (n, i) {
          if (lengths[i] > len) { len = lengths[i]; main = n; }
          n.style.strokeDasharray = lengths[i] + ' ' + (lengths[i] + 1);
        });
      }
      measure();

      var pen = null;
      if (d.pen !== 'false') {
        pen = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        var w = parseFloat(getComputedStyle(main).strokeWidth) || 2;
        var color = d.penColor || getComputedStyle(main).stroke;
        if (!color || color === 'none' || color.indexOf('url(') === 0) color = 'currentColor';
        pen.setAttribute('r', Math.max(4, w * 1.4));
        pen.setAttribute('fill', color);
        pen.style.filter = 'drop-shadow(0 0 ' + w * 2 + 'px ' + color + ')';
        pen.style.transition = 'opacity .2s';
        svg.appendChild(pen);
      }

      var box = scroller(svg);
      var last = -1, queued = false;
      function update() {
        queued = false;
        if (!len) measure();
        if (!len) return;
        var r = svg.getBoundingClientRect();
        var top = 0, h = window.innerHeight;
        if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
        // The tip follows a line across the view (60% of the way down by
        // default): drawn up to wherever that line crosses the drawing.
        var p = (top + h * offset - r.top) / r.height;
        p = Math.max(0, Math.min(1, p));
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        lines.forEach(function (n, i) { n.style.strokeDashoffset = lengths[i] * (1 - p); });
        if (pen) {
          var pt = main.getPointAtLength(len * p);
          pen.setAttribute('cx', pt.x);
          pen.setAttribute('cy', pt.y);
          pen.style.opacity = p > 0.001 && p < 0.999 ? 1 : 0;
        }
        marks.forEach(function (m) {
          var on = p >= m.at;
          if (on === m.on) return;
          m.on = on;
          anime.animate(m.el, on
            ? { opacity: 1, scale: [0.4, 1], duration: 600, ease: 'outElastic(1, .6)' }
            : { opacity: 0, scale: 0.4, duration: 250, ease: 'in(2)' });
        });
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
      update();
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-scroll-drawn-path]').forEach(function (el) {
        if (el._sdp) return;
        el._sdp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<svg data-scroll-drawn-path viewBox="0 0 200 600" fill="none">
  <path d="M100 0 C20 150 180 300 100 600" stroke="#a855f7" stroke-width="4"/>
  <circle data-at="0.5" cx="100" cy="300" r="8" fill="#a855f7"/>
</svg>