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
- Paste the snippet just before
</head>. - Add
data-scroll-shape-morphto 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. - Optionally give each path a
data-labeland add adata-captionelement to show it.
Options
Add any of these attributes to the same element.
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, from 0 to 0.9. Default 0.35. |
data-spin | Degrees 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.
<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><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>