Copied!
Gallery

Scroll velocity shader

A column of photos whose WebGL shader waves the picture and splits its colours the faster you scroll, then settles back.

← Library

Element · Vanilla JS + WebGLUpdated November 6, 2026

ScrollElementVanilla JS + WebGL

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.

SectionGallery
TechniquesWebGL, Scroll-driven
StackVanilla JS, WebGL
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byScroll 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.

Scroll camera landscapeA procedural low-poly mountain range that the camera flies through as you scroll, with a sun that moves from dawn to dusk. Particle wave heroA big two-line headline over a dark valley of green particles that roll in slow waves, swell under the pointer and fly past as the hero scrolls away. Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport.

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.