Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Scroll velocity shader" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-velocity-shader.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-velocity-shader.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- auto: 1
- strength: 0.9
- distortion: 0.03
- split: 1
- settle: 0.8
- wave: 1
- title: Scroll speed, made visible
- text: Every photo runs its own shader. The faster you scroll, the more it waves and splits into colour, then it settles.
- background: #0c1e18
- line: #085e3d
- ink: #fbf8f1
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/scroll-velocity-shader.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-svs">
<div class="ef-svs__scroll" tabindex="0" role="region" aria-label="Photo column. Scroll it to bend the photos">
<header class="ef-svs__head">
<h3 class="ef-svs__title">Scroll speed, made visible</h3>
<p class="ef-svs__text">Every photo runs its own shader. The faster you scroll, the more it waves and splits into colour, then it settles.</p>
</header>
<div class="ef-svs__frame" data-img="/img/library/corgi-4.jpg"><canvas class="ef-svs__c" role="img" aria-label="A corgi and a terrier running down a dirt road"></canvas></div>
<div class="ef-svs__frame" data-img="/img/library/corgi-1.jpg"><canvas class="ef-svs__c" role="img" aria-label="Corgi puppy in front of an orange wall"></canvas></div>
<div class="ef-svs__frame" data-img="/img/library/corgi-5.jpg"><canvas class="ef-svs__c" role="img" aria-label="Corgi lying down on a white background"></canvas></div>
<div class="ef-svs__frame" data-img="/img/library/corgi-2.jpg"><canvas class="ef-svs__c" role="img" aria-label="Corgi sitting in a field of flowers"></canvas></div>
<div class="ef-svs__frame" data-img="/img/library/corgi-6.jpg"><canvas class="ef-svs__c" role="img" aria-label="Corgi standing on a white background"></canvas></div>
<div class="ef-svs__frame" data-img="/img/library/corgi-3.jpg"><canvas class="ef-svs__c" role="img" aria-label="Corgi standing on a rock among autumn leaves"></canvas></div>
<div class="ef-svs__spacer"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-svs { --ef-svs-bg: #0c1e18; --ef-svs-line: #085e3d; --ef-svs-ink: #fbf8f1; --ef-svs-acc: #e6ff55; position: relative; width: 100%; min-height: 28rem; overflow: hidden; border-radius: 1rem; background: var(--ef-svs-bg); color: var(--ef-svs-ink); --ef-svs-w: 800px; --ef-svs-h: 28rem; }
.ef-svs .ef-svs__scroll { position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; outline: none; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--ef-svs-ink) 25%, transparent) transparent; }
.ef-svs .ef-svs__scroll:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-svs-acc); border-radius: 1rem; }
.ef-svs .ef-svs__head { box-sizing: border-box; min-height: calc(var(--ef-svs-h) * 0.5); padding: 2rem 8% 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.ef-svs .ef-svs__title { margin: 0; max-width: 18ch; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.5rem, calc(var(--ef-svs-w) * 0.06), 4rem); font-weight: 500; line-height: 1.05; letter-spacing: -0.02em; color: var(--ef-svs-ink); opacity: 1; }
.ef-svs .ef-svs__text { margin: 1.1rem 0 0; max-width: 36rem; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: clamp(0.85rem, calc(var(--ef-svs-w) * 0.016), 1.1rem); line-height: 1.5; color: var(--ef-svs-ink); opacity: 0.78; }
.ef-svs .ef-svs__frame { position: relative; box-sizing: border-box; width: 80%; max-width: 600px; aspect-ratio: 16 / 9; margin: calc(var(--ef-svs-h) * 0.2) auto; padding: 0.5rem; border-radius: 10px; outline: dashed 2px var(--ef-svs-line); background-color: transparent; background-repeat: no-repeat; background-position: center; background-size: cover; background-origin: content-box; background-clip: content-box; }
.ef-svs .ef-svs__c { position: absolute; inset: 0.5rem; display: block; width: calc(100% - 1rem); height: calc(100% - 1rem); }
.ef-svs .ef-svs__spacer { height: calc(var(--ef-svs-h) * 0.1); }JavaScript
// Enrichr Library · https://enrichr.dev
// Scroll velocity shader: a column of photos, each drawn by a small WebGL fragment shader that bends the picture and splits
// its red, green and blue apart in proportion to how fast you scroll. Scroll speed is read from the page and from the
// column's own scroller, in px/s, clamped to 2,000 and divided by 1,000. When a reading is stronger than the effect already
// showing, it takes over and then eases back to zero over 0.8 s on a sine in-out curve, the same rule as the original GSAP
// ScrollTrigger version. The shader pushes each pixel along a sine and a cosine wave whose size is 0.03 x that strength and
// shifts the three colour channels sideways by different amounts, in the scroll direction. One WebGL context draws every
// frame and copies it to that photo's own canvas, so the canvases scroll natively with the page and no browser context limit
// is hit. Autoplay glides the column to the next photo every few seconds and feeds the matching scroll speed in, so a preview
// moves on its own; it stops while you scroll, touch or hover the column. Reduced motion shows the plain photos and no
// autoplay. Without WebGL the photos stay as CSS backgrounds. Drawing stops off screen, in hidden tabs, and whenever the
// effect has settled to zero.
// mount(root, { auto ("1" glide on its own, "0" off), strength (autoplay speed, 0-1), distortion (wave size at full speed),
// split (colour split, x), settle (s back to rest), wave (wave speed, x), title, text, background, line, ink })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-svs");
var scroller = root.querySelector(".ef-svs__scroll");
var titleEl = root.querySelector(".ef-svs__title");
var textEl = root.querySelector(".ef-svs__text");
var frames = Array.prototype.slice.call(root.querySelectorAll(".ef-svs__frame")).map(function (el) {
return { el: el, c: el.querySelector(".ef-svs__c"), ctx: null, url: el.getAttribute("data-img") || "", tex: null, iw: 1, ih: 1, w: 0, h: 0, seen: false, dirty: true };
});
var opts = {}, raf = 0, last = 0, clock = 0, visible = true, destroyed = false, offs = [], io = null, fio = null, ro = null;
var vp = { v: 0, s: 0 }, tw = null; // shared velocity proxy: v signed (-1..1), s strength (0..1), tw = the ease back to rest
var glc = null, gl = null, prog = null, buf = null, u = {}, ok = false, cache = {};
var hold = 0, glide = null, nextAt = 0, autoIdx = -1;
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function now() { return performance.now(); }
// ---- velocity: ScrollTrigger-style tracker (samples at most every 50 ms, reads zero after 500 ms without movement)
function tracker(read) {
var v1 = read(), v2 = v1, t1 = now(), t2 = t1;
return {
update: function () {
var t = now(), val = read();
if (val === v1) return;
if (t - t1 > 50) { v2 = v1; v1 = val; t2 = t1; t1 = t; } else { v1 = v2 + (val - v2) / (t - t2) * (t1 - t2); }
},
velocity: function () {
var t = now();
return (t1 === t2 || t - t2 > 500) ? 0 : (v1 - v2) / (t1 - t2) * 1000;
}
};
}
var pageV = tracker(function () { return window.scrollY || window.pageYOffset || 0; });
var colV = tracker(function () { return scroller.scrollTop; });
// the original's ScrollTrigger onUpdate: a stronger reading takes over, then tweens back to 0 (sine.inOut, overwrite)
function feed(raw) {
raw = Math.max(-2000, Math.min(2000, raw));
var norm = raw / 1000, strength = Math.min(1, Math.abs(norm));
if (strength > Math.abs(vp.s)) { vp.v = norm; vp.s = strength; tw = { v: norm, s: strength, t: now() }; }
}
function settle(t) {
if (!tw) return;
var p = Math.min(1, (t - tw.t) / (opts.settle * 1000)), e = (1 - Math.cos(Math.PI * p)) / 2;
vp.v = tw.v * (1 - e); vp.s = tw.s * (1 - e);
if (p >= 1) { tw = null; vp.v = 0; vp.s = 0; frames.forEach(function (f) { f.dirty = true; }); }
}
// ---- WebGL: one context, a full-screen triangle, cover-fit UVs worked out in the vertex shader like the original
var VS = "attribute vec2 p;uniform vec2 uTS,uQS;varying vec2 vC;" +
"void main(){vec2 uv=p*.5+.5;float tr=uTS.x/uTS.y,qr=uQS.x/uQS.y;vec2 s=vec2(1.);if(qr>tr){s.y=tr/qr;}else{s.x=qr/tr;}" +
"vC=uv*s+(1.-s)*.5;gl_Position=vec4(p,0.,1.);}";
var FS = "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
"uniform sampler2D uT;uniform float uTime,uV,uS,uAmt,uSplit;varying vec2 vC;" +
"void main(){vec2 tc=vC;float amt=uAmt*uS;float t=uTime*.8;" +
"tc.y+=sin((tc.x*8.)+t)*amt;tc.x+=cos((tc.y*6.)-t*.8)*amt*.6;" +
"float d=sign(uV)*uSplit;" +
"float r=texture2D(uT,tc+vec2(amt*.5*d,0.)).r;float g=texture2D(uT,tc+vec2(amt*.25*d,0.)).g;float b=texture2D(uT,tc+vec2(-amt*.35*d,0.)).b;" +
"gl_FragColor=vec4(r,g,b,1.);}";
function sh(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
function setup() {
glc = document.createElement("canvas");
try { gl = glc.getContext("webgl", { antialias: false, alpha: false, depth: false, powerPreference: "low-power" }); } catch (e) { gl = null; }
if (!gl) return false;
var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
if (!v || !f) return false;
prog = gl.createProgram(); gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
gl.deleteShader(v); gl.deleteShader(f);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false;
gl.useProgram(prog);
buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(prog, "p"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
["uTS", "uQS", "uT", "uTime", "uV", "uS", "uAmt", "uSplit"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
gl.uniform1i(u.uT, 0);
return true;
}
// power-of-two copy of the photo so it can have mipmaps (smooth when shrunk); the true size goes to the cover maths
function upload(img) {
var tex = gl.createTexture(), side = 1024, c = document.createElement("canvas");
c.width = c.height = side; c.getContext("2d").drawImage(img, 0, 0, side, side);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
function load(f) {
if (!f.url) return;
var hit = cache[f.url];
function done(entry, g) { g.tex = entry.tex; g.iw = entry.w; g.ih = entry.h; g.dirty = true; }
if (hit && hit.tex) { done(hit, f); return; }
if (hit) { hit.wait.push(f); return; }
hit = cache[f.url] = { tex: null, w: 1, h: 1, wait: [f] };
var img = new Image(); img.crossOrigin = "anonymous"; img.decoding = "async";
img.onload = function () {
if (destroyed || !gl) return;
try { hit.tex = upload(img); } catch (e) { return; } // a remote photo without CORS: the CSS background stays
hit.w = img.naturalWidth || 1; hit.h = img.naturalHeight || 1;
hit.wait.forEach(function (g) { done(hit, g); }); hit.wait = [];
};
img.src = f.url;
}
// the column is laid out in units of the box (the original used the viewport): 20% of its height between photos
function sizeBox() {
box.style.setProperty("--ef-svs-w", Math.max(1, box.clientWidth) + "px");
box.style.setProperty("--ef-svs-h", Math.max(1, box.clientHeight) + "px");
}
function sizeFrames() {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
frames.forEach(function (f) {
var w = Math.max(1, Math.round(f.c.clientWidth * dpr)), h = Math.max(1, Math.round(f.c.clientHeight * dpr));
if (w !== f.w || h !== f.h) { f.w = w; f.h = h; f.c.width = w; f.c.height = h; f.dirty = true; }
});
}
function draw(f) {
if (glc.width !== f.w || glc.height !== f.h) { glc.width = f.w; glc.height = f.h; }
gl.viewport(0, 0, f.w, f.h);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, f.tex);
gl.uniform2f(u.uTS, f.iw, f.ih); gl.uniform2f(u.uQS, f.w, f.h);
gl.uniform1f(u.uTime, clock); gl.uniform1f(u.uV, vp.v); gl.uniform1f(u.uS, vp.s);
gl.uniform1f(u.uAmt, opts.distortion); gl.uniform1f(u.uSplit, opts.split);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (!f.ctx) f.ctx = f.c.getContext("2d");
f.ctx.clearRect(0, 0, f.w, f.h);
f.ctx.drawImage(glc, 0, 0);
f.dirty = false;
}
// ---- autoplay: glide the column to the next photo and feed in the speed a real scroll that fast would read
function autoOn() { return !reduce && String(opts.auto) !== "0"; }
function target(i) { var f = frames[i].el; return Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, f.offsetTop - (scroller.clientHeight - f.offsetHeight) / 2)); }
function startGlide(t) {
autoIdx = (autoIdx + 1) % frames.length;
var from = scroller.scrollTop, to = target(autoIdx);
if (Math.abs(to - from) < 2) { nextAt = t + 2600; return; }
glide = { from: from, to: to, t: t, d: autoIdx === 0 ? 1400 : 900 };
}
function stepGlide(t) {
var p = Math.min(1, (t - glide.t) / glide.d);
var e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; // power2.inOut
var de = p < 0.5 ? 12 * p * p : 3 * Math.pow(-2 * p + 2, 2); // its slope, 3 at the middle
scroller.scrollTop = glide.from + (glide.to - glide.from) * e;
feed((glide.to > glide.from ? 1 : -1) * opts.strength * 1000 * de / 3);
if (p >= 1) { glide = null; nextAt = t + 2600; }
}
function pause(ms) { hold = Math.max(hold, now() + ms); glide = null; }
function loop(t) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t;
if (!visible || document.hidden) return;
clock = (clock + dt * opts.wave) % 39.2699; // both waves repeat every 2 PI / 0.16 s, so wrapping keeps float precision
if (autoOn() && t > hold) {
if (glide) stepGlide(t);
else if (!nextAt) nextAt = t + 1200;
else if (t >= nextAt) startGlide(t);
}
settle(t);
if (!ok) return;
var moving = vp.s > 0;
for (var i = 0; i < frames.length; i++) {
var f = frames[i];
if (f.seen && f.tex && (moving || f.dirty)) draw(f);
}
}
function on(el, type, fn, opt) { el.addEventListener(type, fn, opt); offs.push(function () { el.removeEventListener(type, fn, opt); }); }
function onPageScroll() { if (visible) { pageV.update(); feed(pageV.velocity()); } }
function onColScroll() { colV.update(); if (!glide) feed(colV.velocity()); }
function onUser() { pause(3000); }
function update(next) {
opts = Object.assign({ auto: "1", strength: 0.9, distortion: 0.03, split: 1, settle: 0.8, wave: 1, title: "Scroll speed, made visible", text: "Every photo runs its own shader. The faster you scroll, the more it waves and splits into colour, then it settles.", background: "#0c1e18", line: "#085e3d", ink: "#fbf8f1" }, opts, next || {});
opts.strength = Math.max(0, Math.min(1, num(opts.strength, 0.9))); opts.distortion = Math.max(0, num(opts.distortion, 0.03));
opts.split = num(opts.split, 1); opts.settle = Math.max(0.05, num(opts.settle, 0.8)); opts.wave = Math.max(0, num(opts.wave, 1));
box.style.setProperty("--ef-svs-bg", opts.background);
box.style.setProperty("--ef-svs-line", opts.line);
box.style.setProperty("--ef-svs-ink", opts.ink);
titleEl.textContent = opts.title; textEl.textContent = opts.text;
titleEl.hidden = !opts.title; textEl.hidden = !opts.text;
if (!autoOn()) glide = null;
frames.forEach(function (f) { f.dirty = true; });
}
frames.forEach(function (f) { if (f.url) f.el.style.backgroundImage = "url(\"" + f.url.replace(/"/g, "%22") + "\")"; });
sizeBox();
update(o);
ok = !reduce && setup();
if (!ok) frames.forEach(function (f) { f.c.style.display = "none"; });
if (ok) {
frames.forEach(load);
sizeFrames();
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box);
fio = new IntersectionObserver(function (es) { es.forEach(function (e) { for (var i = 0; i < frames.length; i++) if (frames[i].el === e.target) frames[i].seen = e.isIntersecting; }); }, { root: scroller, rootMargin: "60px 0px" });
frames.forEach(function (f) { fio.observe(f.el); });
} else frames.forEach(function (f) { f.seen = true; });
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; sizeBox(); if (ok) sizeFrames(); }); ro.observe(box); }
if (ok) {
on(window, "scroll", onPageScroll, { passive: true });
on(scroller, "scroll", onColScroll, { passive: true });
["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (t) { on(scroller, t, onUser, { passive: true }); });
on(scroller, "pointerenter", function (e) { if (e.pointerType === "mouse") pause(1e9); });
on(scroller, "pointerleave", function (e) { if (e.pointerType === "mouse") { hold = now() + 1500; nextAt = 0; } });
last = now(); raf = requestAnimationFrame(loop);
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
offs.forEach(function (f) { f(); }); offs = [];
if (io) io.disconnect();
if (fio) fio.disconnect();
if (ro) ro.disconnect();
if (gl) {
Object.keys(cache).forEach(function (k) { if (cache[k].tex) gl.deleteTexture(cache[k].tex); });
if (buf) gl.deleteBuffer(buf);
if (prog) gl.deleteProgram(prog);
var lose = gl.getExtension("WEBGL_lose_context"); if (lose) lose.loseContext();
}
cache = {}; gl = null;
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"auto":1,"strength":0.9,"distortion":0.03,"split":1,"settle":0.8,"wave":1,"title":"Scroll speed, made visible","text":"Every photo runs its own shader. The faster you scroll, the more it waves and splits into colour, then it settles.","background":"#0c1e18","line":"#085e3d","ink":"#fbf8f1"});How it works
Scroll speed is measured in pixels per second from both the page and the column's own scroller, the way GSAP ScrollTrigger reports it: samples at most every 50 ms, read as zero after half a second without movement. The speed is clamped to 2,000, divided by 1,000 and kept with its direction. If a new reading is stronger than the effect already showing, it takes over and then eases back to zero over 0.8 seconds on a sine in-out curve. A small fragment shader draws each photo with a cover fit worked out in the vertex shader, moves every pixel along a sine and a cosine wave whose size is 0.03 times that strength, and samples red, green and blue at slightly different sideways offsets in the scroll direction, which gives the colour split. One WebGL context draws each photo in turn and copies the result into that photo's own canvas, so the canvases scroll natively and the page never runs out of WebGL contexts. Autoplay glides the column to the next photo every few seconds and feeds in the speed a real scroll that fast would read, so a preview moves without anyone scrolling.
Accessibility and performance
Each photo canvas has a text label, and the heading and intro are real text. The column is a focusable region, so arrow keys and Page Down scroll it. With prefers-reduced-motion the photos are shown plain, with no shader, no distortion and no autoplay. Autoplay stops while you scroll, touch or hover the column. The distortion only follows your own scrolling and settles in under a second.
There is one WebGL context however many photos there are, and each photo is drawn only while it is near the visible part of the column and the effect is moving; once it settles to zero nothing is redrawn. Photos are uploaded once as 1024 px power-of-two textures with mipmaps, the pixel ratio is capped at 2, and everything pauses off screen and in hidden tabs. On destroy the textures, buffer and program are deleted and the context is released.
| Section | Gallery |
|---|---|
| Techniques | WebGL, Scroll-driven |
| Stack | Vanilla JS, WebGL |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Scroll Shader by Enrichr on CodePen. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Does it need three.js or GSAP?
No. The original used one three.js scene per photo and a GSAP ScrollTrigger to read scroll speed. Here a single raw WebGL shader does the drawing and a few lines of JavaScript track the speed and ease it back to rest with the same 0.8 second sine in-out curve.
Why does it still move when nobody scrolls?
That is the autoplay, there so the preview shows the effect. It glides to the next photo every few seconds and pauses whenever you scroll, touch or hover the column. Set Autoplay to Off to make it react only to real scrolling.
Can I use my own photos?
Yes. Change the data-img address and the aria-label on each frame in the markup. Photos from another site need CORS headers, or the frame falls back to a plain CSS background with no shader. Photos: Alvan Nee and fatty corgi on Unsplash.
Will lots of photos slow the page?
Not much. Every photo shares one WebGL context, only photos near the visible area are drawn, and nothing is drawn at all once the effect has settled.