scroll filmstrip frame cinema parallax storytelling
Scroll Filmstrip
scroll-filmstrip.js
export default function scrollFilmstrip(ctx, state) {
const FRAME_COUNT = 20;
const SCROLL_PER_FRAME = 60; // px of scroll per frame step
let frames = [];
function buildFrames(width, height) {
frames = [];
for (let i = 0; i < FRAME_COUNT; i++) {
const t = i / (FRAME_COUNT - 1);
const fc = document.createElement('canvas');
fc.width = width;
fc.height = height;
const fctx = fc.getContext('2d');
// Background gradient shifts hue from blue-violet to warm amber
const grad = fctx.createLinearGradient(0, 0, width, height);
const h1 = 280 - t * 160; // 280 (violet) → 120 (green-teal) — or change to your palette
const l1 = 22 + t * 12;
grad.addColorStop(0, `oklch(${l1}% 0.22 ${h1})`);
grad.addColorStop(1, `oklch(${l1 + 8}% 0.14 ${h1 + 40})`);
fctx.fillStyle = grad;
fctx.fillRect(0, 0, width, height);
// Film-frame border: thin white rectangle inset
fctx.strokeStyle = `oklch(100% 0 0 / 0.12)`;
fctx.lineWidth = 1.5;
const inset = Math.round(width * 0.04);
fctx.strokeRect(inset, inset, width - inset * 2, height - inset * 2);
// Sprocket holes — top and bottom rows
fctx.fillStyle = `oklch(0% 0 0 / 0.35)`;
const holeW = Math.round(width * 0.032);
const holeH = Math.round(height * 0.045);
const holeSpacing = Math.round(width * 0.07);
const holeY1 = Math.round(height * 0.025);
const holeY2 = height - holeY1 - holeH;
for (let x = holeSpacing; x < width - holeW; x += holeSpacing * 2) {
fctx.beginPath();
fctx.roundRect(x, holeY1, holeW, holeH, 2);
fctx.fill();
fctx.beginPath();
fctx.roundRect(x, holeY2, holeW, holeH, 2);
fctx.fill();
}
// Center label: frame counter
fctx.fillStyle = `oklch(95% 0.01 75 / 0.85)`;
const fs = Math.round(Math.min(width, height) * 0.1);
fctx.font = `600 ${fs}px system-ui, sans-serif`;
fctx.textAlign = 'center';
fctx.textBaseline = 'middle';
fctx.fillText(`${String(i + 1).padStart(2, '0')} / ${FRAME_COUNT}`, width / 2, height / 2);
frames.push(fc);
}
}
buildFrames(state.width, state.height);
return {
resize({ width, height }) {
buildFrames(width, height);
},
frame({ width: _width, height: _height, reducedMotion, time }) {
let scrollTop = 0;
if (typeof window !== 'undefined') {
const hasScroll = document.body.scrollHeight > window.innerHeight + 4;
if (hasScroll) {
scrollTop = Math.max(0, window.scrollY);
} else {
// No scrollable content (e.g. preview iframe) — ping-pong through frames
scrollTop = reducedMotion
? 0
: Math.abs(Math.sin(time * 0.25)) * FRAME_COUNT * SCROLL_PER_FRAME;
}
}
const frameIndex = reducedMotion
? 0
: Math.min(FRAME_COUNT - 1, Math.floor(scrollTop / SCROLL_PER_FRAME));
const f = frames[frameIndex];
if (f) ctx.drawImage(f, 0, 0);
},
destroy() {
frames = [];
},
};
}
embed.html
<canvas id="canvas-effect" style="width:100%;height:400px;display:block" aria-hidden="true"></canvas>
<script>
/**
* mountCanvas — Minimal runtime for Canvas effects.
*
* Handles HiDPI scaling, ResizeObserver, RAF loop, pointer tracking,
* reduced-motion, and cleanup. The effect only implements draw logic.
*
* @param {HTMLCanvasElement} canvas
* @param {(ctx: CanvasRenderingContext2D, state: object) => object} createEffect
* @param {object} [options]
* @returns {() => void} destroy function
*/
function mountCanvas(canvas, createEffect, options = {}) {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('2D context not available');
const opts = {
animate: true,
clear: true,
maxDpr: 2,
pauseWhenHidden: true,
respectReducedMotion: true,
...options,
};
const reducedMotion =
opts.respectReducedMotion && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const state = {
canvas,
ctx,
width: 0,
height: 0,
pixelWidth: 0,
pixelHeight: 0,
dpr: 1,
time: 0,
delta: 0,
frame: 0,
playing: false,
reducedMotion,
pointer: { x: 0, y: 0, inside: false, down: false },
};
let rafId = 0;
let lastTime = 0;
let disposed = false;
function measure() {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, rect.width);
const h = Math.max(1, rect.height);
const dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr);
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
state.width = w;
state.height = h;
state.pixelWidth = pw;
state.pixelHeight = ph;
state.dpr = dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clear() {
ctx.clearRect(0, 0, state.width, state.height);
}
const effect = createEffect(ctx, state) || {};
function onResize() {
measure();
effect.resize?.(state);
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function loop(now) {
if (disposed) return;
if (opts.pauseWhenHidden && document.hidden) {
rafId = requestAnimationFrame(loop);
return;
}
state.delta = lastTime ? (now - lastTime) / 1000 : 0;
state.time = now / 1000;
state.frame += 1;
lastTime = now;
if (opts.clear) clear();
effect.frame?.(state);
rafId = requestAnimationFrame(loop);
}
function onPointerMove(e) {
const rect = canvas.getBoundingClientRect();
state.pointer.x = e.clientX - rect.left;
state.pointer.y = e.clientY - rect.top;
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function onPointerEnter() {
state.pointer.inside = true;
}
function onPointerLeave() {
state.pointer.inside = false;
state.pointer.down = false;
}
function onPointerDown() {
state.pointer.down = true;
}
function onPointerUp() {
state.pointer.down = false;
}
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerenter', onPointerEnter);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);
measure();
if (opts.animate && !reducedMotion) {
state.playing = true;
rafId = requestAnimationFrame(loop);
} else {
effect.frame?.(state);
}
return function destroy() {
disposed = true;
state.playing = false;
cancelAnimationFrame(rafId);
ro.disconnect();
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerenter', onPointerEnter);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
effect.destroy?.(state);
};
}
const __effect = function scrollFilmstrip(ctx, state) {
const FRAME_COUNT = 20;
const SCROLL_PER_FRAME = 60; // px of scroll per frame step
let frames = [];
function buildFrames(width, height) {
frames = [];
for (let i = 0; i < FRAME_COUNT; i++) {
const t = i / (FRAME_COUNT - 1);
const fc = document.createElement('canvas');
fc.width = width;
fc.height = height;
const fctx = fc.getContext('2d');
// Background gradient shifts hue from blue-violet to warm amber
const grad = fctx.createLinearGradient(0, 0, width, height);
const h1 = 280 - t * 160; // 280 (violet) → 120 (green-teal) — or change to your palette
const l1 = 22 + t * 12;
grad.addColorStop(0, `oklch(${l1}% 0.22 ${h1})`);
grad.addColorStop(1, `oklch(${l1 + 8}% 0.14 ${h1 + 40})`);
fctx.fillStyle = grad;
fctx.fillRect(0, 0, width, height);
// Film-frame border: thin white rectangle inset
fctx.strokeStyle = `oklch(100% 0 0 / 0.12)`;
fctx.lineWidth = 1.5;
const inset = Math.round(width * 0.04);
fctx.strokeRect(inset, inset, width - inset * 2, height - inset * 2);
// Sprocket holes — top and bottom rows
fctx.fillStyle = `oklch(0% 0 0 / 0.35)`;
const holeW = Math.round(width * 0.032);
const holeH = Math.round(height * 0.045);
const holeSpacing = Math.round(width * 0.07);
const holeY1 = Math.round(height * 0.025);
const holeY2 = height - holeY1 - holeH;
for (let x = holeSpacing; x < width - holeW; x += holeSpacing * 2) {
fctx.beginPath();
fctx.roundRect(x, holeY1, holeW, holeH, 2);
fctx.fill();
fctx.beginPath();
fctx.roundRect(x, holeY2, holeW, holeH, 2);
fctx.fill();
}
// Center label: frame counter
fctx.fillStyle = `oklch(95% 0.01 75 / 0.85)`;
const fs = Math.round(Math.min(width, height) * 0.1);
fctx.font = `600 ${fs}px system-ui, sans-serif`;
fctx.textAlign = 'center';
fctx.textBaseline = 'middle';
fctx.fillText(`${String(i + 1).padStart(2, '0')} / ${FRAME_COUNT}`, width / 2, height / 2);
frames.push(fc);
}
}
buildFrames(state.width, state.height);
return {
resize({ width, height }) {
buildFrames(width, height);
},
frame({ width: _width, height: _height, reducedMotion, time }) {
let scrollTop = 0;
if (typeof window !== 'undefined') {
const hasScroll = document.body.scrollHeight > window.innerHeight + 4;
if (hasScroll) {
scrollTop = Math.max(0, window.scrollY);
} else {
// No scrollable content (e.g. preview iframe) — ping-pong through frames
scrollTop = reducedMotion
? 0
: Math.abs(Math.sin(time * 0.25)) * FRAME_COUNT * SCROLL_PER_FRAME;
}
}
const frameIndex = reducedMotion
? 0
: Math.min(FRAME_COUNT - 1, Math.floor(scrollTop / SCROLL_PER_FRAME));
const f = frames[frameIndex];
if (f) ctx.drawImage(f, 0, 0);
},
destroy() {
frames = [];
},
};
}
const canvas = document.getElementById('canvas-effect');
mountCanvas(canvas, __effect, { animate: true, clear: true });
</script> Details
Animated Reduced Motion aria-hidden
scrollfilmstripframecinemaparallaxstorytelling
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| FRAME_COUNT | number | 20 | Number of pre-rendered frames in the filmstrip |
| SCROLL_PER_FRAME | number | 60 | How many px of scroll advance one frame |
Pre-renders FRAME_COUNT canvas frames at mount time — each with a shifting color gradient, film-sprocket borders, and a frame counter. The frame() callback reads window.scrollY each tick and picks the corresponding pre-rendered bitmap via ctx.drawImage. Zero animation when the page isn’t scrolled; instant frame lookup (no redraw per frame).
With prefers-reduced-motion, stays locked on frame 0 — a static gradient, no scroll coupling.
Swap buildFrames() content with your own imagery (e.g. drawImage from a sprite sheet) to build a genuine scroll-scrubbed video player.