← Scroll

Scroll Shape Morph

A shape pins to the screen and morphs into the next one, step by step, as you scroll: here a seed becomes a sprout, a flower and a star, its colour blending as it goes. It holds each shape for a moment, and scrolling back runs it in reverse.

Scroll inside ↓

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-shape-morph to a container holding an <svg> with one <path> per shape, in order. Each should be a single closed outline; they don’t need matching points.
  3. Optionally give each path a data-label and add a data-caption element to show it.

Options

Add any of these attributes to the same element.

data-lengthHow many screen heights of scrolling the whole sequence takes while pinned. Default 2.
data-holdThe share of each step spent holding the shape still, from 0 to 0.9. Default 0.35.
data-spinDegrees the shape turns on each step. Default 0.

It pins with position: sticky, so nothing around it can have overflow: hidden. Each path’s fill colour is blended too. With reduced motion it swaps shapes without the in-between. 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 Shape Morph: an SVG shape pins to the screen and morphs into the
    // next shape, step by step, as you scroll, its colour blending along with
    // it. It holds each shape for a moment before moving on, and scrolling
    // back runs the morphs in reverse.
    // Usage: <div data-scroll-shape-morph>
    //          <svg viewBox="0 0 200 200">
    //            <path d="…" fill="#a855f7" data-label="Idea"/>   (one path per shape, in order)
    //            <path d="…" fill="#ec4899" data-label="Spark"/>
    //          </svg>
    //          <p data-caption></p>   (optional: shows each shape's data-label)
    //        </div>
    // Each path should be a single closed outline; any outlines work, they
    // needn't have matching points.
    // Optional: data-length (how many screen heights of scrolling the whole
    // sequence takes while pinned, default 2), data-hold (the share of each
    // step spent holding the shape still, 0 to 0.9, default 0.35), data-spin
    // (degrees the shape turns on each step, default 0).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { length: 2, hold: 0.35, spin: 0 };
    var POINTS = 160;

    var css =
      '.ssm{position:relative}' +
      '.ssm-pin{position:sticky;top:0;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}' +
      '.ssm-shape{transform-box:fill-box;transform-origin:50% 50%}' +
      '.ssm-cap{transition:opacity .18s,transform .18s}' +
      '.ssm-cap.ssm-out{opacity:0;transform:translateY(6px)}';
    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 ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    // Evenly spaced points round an outline, always running the same way round.
    function sample(path) {
      var len = path.getTotalLength(), pts = [], area = 0, i;
      for (i = 0; i < POINTS; i++) {
        var p = path.getPointAtLength((i / POINTS) * len);
        pts.push([p.x, p.y]);
      }
      for (i = 0; i < POINTS; i++) {
        var a = pts[i], b = pts[(i + 1) % POINTS];
        area += a[0] * b[1] - b[0] * a[1];
      }
      return area < 0 ? pts.reverse() : pts;
    }

    // Turn the points of `next` so each lines up with its nearest point on `prev`, for a tidy morph.
    function align(prev, next) {
      var best = 0, bestD = Infinity;
      for (var k = 0; k < POINTS; k++) {
        var d = 0;
        for (var i = 0; i < POINTS; i += 4) {
          var a = prev[i], b = next[(i + k) % POINTS];
          d += (a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]);
        }
        if (d < bestD) { bestD = d; best = k; }
      }
      return next.slice(best).concat(next.slice(0, best));
    }

    function rgb(colour) {
      var m = /rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/.exec(colour || '');
      return m ? [+m[1], +m[2], +m[3]] : null;
    }

    // Colours blend through hue (round the shorter way), so they stay bright mid-way.
    function hsl(c) {
      var r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
      var max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, h = 0, s = 0, dd = max - min;
      if (dd) {
        s = l > 0.5 ? dd / (2 - max - min) : dd / (max + min);
        h = max === r ? (g - b) / dd + (g < b ? 6 : 0) : max === g ? (b - r) / dd + 2 : (r - g) / dd + 4;
        h *= 60;
      }
      return [h, s * 100, l * 100];
    }

    function mix(a, b, t) {
      var dh = b[0] - a[0];
      if (dh > 180) dh -= 360;
      if (dh < -180) dh += 360;
      var h = (a[0] + dh * t + 360) % 360;
      return 'hsl(' + h.toFixed(1) + ',' + (a[1] + (b[1] - a[1]) * t).toFixed(1) + '%,' + (a[2] + (b[2] - a[2]) * t).toFixed(1) + '%)';
    }

    function setup(el) {
      var d = el.dataset;
      var length = Math.max(0.3, num(d.length, DEFAULTS.length));
      var hold = Math.max(0, Math.min(0.9, num(d.hold, DEFAULTS.hold)));
      var spin = num(d.spin, DEFAULTS.spin);
      var svg = el.querySelector('svg');
      if (!svg) return;
      var paths = Array.prototype.filter.call(svg.querySelectorAll('path'), function (p) { return !p.closest('defs'); });
      if (paths.length < 2) return;
      var caption = el.querySelector('[data-caption]');
      var box = scroller(el);
      var still = reduced();

      // Sample every outline (and its colour), then keep the first path as the one that morphs.
      var shapes = [], fills = [], labels = [];
      paths.forEach(function (p, i) {
        var pts = sample(p);
        shapes.push(i ? align(shapes[i - 1], pts) : pts);
        fills.push(getComputedStyle(p).fill);
        labels.push(p.getAttribute('data-label') || '');
      });
      var shape = paths[0];
      shape.classList.add('ssm-shape');
      for (var i = 1; i < paths.length; i++) paths[i].style.display = 'none';
      var colours = fills.map(function (f) { var c = rgb(f); return c ? hsl(c) : null; });
      var steps = shapes.length - 1;

      var pin = document.createElement('div');
      pin.className = 'ssm-pin';
      while (el.firstChild) pin.appendChild(el.firstChild);
      el.appendChild(pin);
      el.classList.add('ssm');
      if (caption) caption.classList.add('ssm-cap');

      var viewH = 0, queued = false, last = -1, shownLabel = -1, capTimer = 0;

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        pin.style.height = viewH + 'px';
        el.style.height = viewH * (1 + length) + 'px';
        last = -1;
      }

      function setLabel(n) {
        if (!caption || n === shownLabel) return;
        var first = shownLabel < 0;
        shownLabel = n;
        clearTimeout(capTimer);
        if (first || still) { caption.textContent = labels[n]; return; }
        caption.classList.add('ssm-out');
        capTimer = setTimeout(function () {
          caption.textContent = labels[n];
          caption.classList.remove('ssm-out');
        }, 180);
      }

      function update() {
        queued = false;
        var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var y = el.getBoundingClientRect().top - top;
        var p = Math.max(0, Math.min(1, -y / (viewH * length)));
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        // Which step we're on, and how far through its morph (after the hold).
        var s = Math.min(steps - 1, Math.floor(p * steps));
        var f = p * steps - s;
        var t = Math.max(0, Math.min(1, (f - hold / 2) / (1 - hold)));
        t = still ? (t < 0.5 ? 0 : 1) : ease(t);
        var a = shapes[s], b = shapes[s + 1], out = '';
        for (var i = 0; i < POINTS; i++) {
          out += (i ? 'L' : 'M') + (a[i][0] + (b[i][0] - a[i][0]) * t).toFixed(2) + ' ' + (a[i][1] + (b[i][1] - a[i][1]) * t).toFixed(2);
        }
        shape.setAttribute('d', out + 'Z');
        var ca = colours[s], cb = colours[s + 1];
        if (ca && cb) {
          shape.style.fill = mix(ca, cb, t);
        } else {
          shape.style.fill = t < 0.5 ? fills[s] : fills[s + 1];
        }
        if (spin) shape.style.transform = 'rotate(' + ((s + t) * spin).toFixed(2) + 'deg)';
        setLabel(t < 0.5 ? s : s + 1);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }

      measure();
      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { measure(); update(); });
      if (window.ResizeObserver && box) new ResizeObserver(function () { measure(); update(); }).observe(box);
    }

    function init() {
      document.querySelectorAll('[data-scroll-shape-morph]').forEach(function (el) {
        if (el._ssm) return;
        el._ssm = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scroll-shape-morph>
  <svg viewBox="0 0 200 200">
    <path d="M30 100 A70 70 0 1 1 170 100 A70 70 0 1 1 30 100Z" fill="#f59e0b" data-label="Seed"/>
    <path d="M100 18 C150 52 162 132 100 184 C38 132 50 52 100 18Z" fill="#22c55e" data-label="Sprout"/>
  </svg>
  <p data-caption></p>
</div>