Scroll Frame Sequence
The section pins and a sequence of frames plays as you scroll, so a product turns round on a turntable under the reader’s thumb. Scroll back and it turns the other way. Point it at numbered image files and it preloads them and draws them on a canvas.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Export your frames as numbered images (e.g.
shoe-001.webptoshoe-060.webp). - Add an empty
<div data-scroll-frame-sequence>withdata-src(the file name, with{n}for the number),data-countand, if the numbers have leading zeros,data-pad. Or put the frames inside it as images instead.
Options
Add any of these attributes to the same element.
data-src | The frames’ address, with {n} where the number goes. |
data-count | How many frames there are. |
data-pad | Pad the numbers with zeros to this many digits, e.g. 3 for 001. Default none. |
data-start | The first frame’s number. Default 1. |
data-length | How many screen heights of scrolling the frames take while pinned. Default 1.5; 0 plays them as it passes, without pinning. |
data-turns | Play the sequence this many times over the scroll. Default 1. |
data-smooth | How quickly it catches up with the scroll, from 0 to 1. Default 0.25; 1 jumps straight to each frame. |
Give the element a height when it isn’t pinned; while pinned it takes the height of the view. 40–120 frames of a few dozen KB each is the sweet spot. The demo’s frames are inline SVGs. With reduced motion it shows the first frame. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Frame Sequence: a sequence of frames plays as you scroll, so a
// product turns on a turntable (or anything else filmed frame by frame)
// under the reader's thumb. The section pins while the frames play.
// Usage: <div data-scroll-frame-sequence data-src="/frames/shoe-{n}.webp"
// data-count="60" data-pad="3"></div>
// ({n} becomes 001, 002 … 060), or put the frames inside it:
// <div data-scroll-frame-sequence>
// <img src="frame-1.jpg" alt=""><img src="frame-2.jpg" alt="">…
// </div>
// Optional: data-start (first number, default 1), data-length (how many
// screen heights of scrolling the frames take while pinned, default 1.5;
// 0 plays them as the section passes without pinning), data-turns (play
// the sequence this many times over the scroll, default 1), data-smooth
// (0 to 1: how quickly it catches up with the scroll, default 0.25; 1 to
// jump straight to each frame).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { start: 1, length: 1.5, turns: 1, smooth: 0.25 };
var css =
'.sfs{position:relative}' +
'.sfs-pin{position:sticky;top:0;display:flex;align-items:center;justify-content:center;overflow:hidden}' +
'.sfs-frames{position:relative;display:grid;width:100%;height:100%;place-items:center}' +
'.sfs-frames>*{grid-area:1/1;max-width:100%;max-height:100%;visibility:hidden}' +
'.sfs-frames>.sfs-on{visibility:visible}' +
'.sfs-frames>canvas{width:100%;height:100%;visibility:visible}';
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 length = Math.max(0, num(d.length, DEFAULTS.length));
var turns = Math.max(1, num(d.turns, DEFAULTS.turns));
var smooth = Math.min(1, Math.max(0.01, num(d.smooth, DEFAULTS.smooth)));
var still = reduced();
var box = scroller(el);
var pin = document.createElement('div');
pin.className = 'sfs-pin';
var frames = document.createElement('div');
frames.className = 'sfs-frames';
var kids = Array.prototype.slice.call(el.children);
kids.forEach(function (k) { frames.appendChild(k); });
pin.appendChild(frames);
el.appendChild(pin);
el.classList.add('sfs');
// Frames from data-src are drawn on a canvas; frames inside are shown
// one at a time.
var count, imgs = null, canvas = null, ctx = null;
if (d.src && d.count) {
count = Math.max(1, Math.round(+d.count));
var first = num(d.start, DEFAULTS.start), pad = num(d.pad, 0);
imgs = [];
for (var i = 0; i < count; i++) {
var n = String(first + i);
while (n.length < pad) n = '0' + n;
var img = new Image();
img.decoding = 'async';
img.onload = draw;
img.src = d.src.replace('{n}', n);
imgs.push(img);
}
canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
frames.appendChild(canvas);
ctx = canvas.getContext('2d');
} else {
count = kids.length;
if (!count) return;
}
var viewH = 0, shown = -1, pos = 0, target = 0, raf = 0, queued = false;
function measure() {
viewH = box ? box.clientHeight : window.innerHeight;
var pinned = length > 0 && !still;
if (pinned) {
pin.style.position = '';
pin.style.height = viewH + 'px';
el.style.height = viewH * (1 + length) + 'px';
} else {
// Not pinned: the frames fill the element as you've sized it.
pin.style.position = 'static';
pin.style.height = el.clientHeight > 40 ? el.clientHeight + 'px' : Math.round(viewH * 0.7) + 'px';
}
if (canvas) {
var dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(frames.clientWidth * dpr);
canvas.height = Math.round(frames.clientHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
shown = -1;
draw();
}
}
// Closest loaded frame to i, so a slow connection still shows something.
function loaded(i) {
for (var k = 0; k < count; k++) {
var a = imgs[(i + k) % count], b = imgs[(i - k + count) % count];
if (b.complete && b.naturalWidth) return b;
if (a.complete && a.naturalWidth) return a;
}
return null;
}
function draw() {
var i = Math.round(pos * (count - 1) * turns) % count;
if (i === shown && !imgs) return;
if (imgs) {
var img = loaded(i);
if (!img) return;
var w = frames.clientWidth, h = frames.clientHeight;
var s = Math.min(w / img.naturalWidth, h / img.naturalHeight);
var dw = img.naturalWidth * s, dh = img.naturalHeight * s;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
} else {
if (shown >= 0 && kids[shown]) kids[shown].classList.remove('sfs-on');
kids[i].classList.add('sfs-on');
}
shown = i;
}
function progress() {
var r = el.getBoundingClientRect();
var top = 0;
if (box) top = box.getBoundingClientRect().top + box.clientTop;
var p = length > 0
? (top - r.top) / (viewH * length)
: (top + viewH - r.top) / (viewH + r.height);
return Math.max(0, Math.min(1, p));
}
function tick() {
raf = 0;
pos += (target - pos) * smooth;
if (Math.abs(target - pos) < 0.0005) pos = target;
draw();
if (pos !== target) raf = requestAnimationFrame(tick);
}
function update() {
queued = false;
target = progress();
if (!raf) raf = requestAnimationFrame(tick);
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
measure();
if (still) { draw(); return; }
pos = target = progress();
draw();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measure(); onScroll(); });
if (window.ResizeObserver) new ResizeObserver(function () { measure(); onScroll(); }).observe(frames);
}
function init() {
document.querySelectorAll('[data-scroll-frame-sequence]').forEach(function (el) {
if (el._sfs) return;
el._sfs = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-scroll-frame-sequence
data-src="/frames/shoe-{n}.webp" data-count="60" data-pad="3"></div>
<!-- or with the frames inside: -->
<div data-scroll-frame-sequence>
<img src="turn-1.jpg" alt="">
<img src="turn-2.jpg" alt="">
…
</div>