scroll to shear

·Playground

Scroll shear.

Scroll. The faster you go, the more it shears. The image leans into the velocity, channels split apart, then settle back to clean when you stop.

How it works

01 Eased scroll velocity

Instead of a scroll event listener, window.scrollY is sampled directly inside the RAF loop each frame. The raw delta is clamped to ±200 px (preventing a spike if the canvas was off-screen) then fed into an exponential ease: vel += (raw - vel) * 0.15. The result is normalised to −1..1 by dividing by 40 px. When you stop scrolling, raw becomes 0 every frame and the velocity decays to rest on its own. No separate timeout or reset needed.

02 Shear and smear

The fragment shader offsets each pixel's UV.y by a function of its UV.x position and the current velocity: shear = v × (uv.x − 0.5) × 0.65. Columns left of centre shift one way, columns right shift the other. The image leans diagonally into the scroll direction. A second term stretches the y-axis slightly (y = uv.y × (1 + |v|×0.1) − |v|×0.05), adding a smear that makes fast scrolls feel more physical.

03 Chromatic split

The red, green, and blue channels are sampled at slightly different UV offsets proportional to velocity. Red leads in the scroll direction; blue lags behind; green stays at the base UV. At rest all three channels overlap and the image is clean. The split grows with speed and collapses back to zero as velocity eases out.

// 1. Eased velocity — sampled every RAF frame, not on scroll events
const raw  = Math.max(-200, Math.min(200, scrollY - lastScrollY));
lastScrollY = scrollY;
vel += (raw - vel) * 0.15;               // exponential ease toward raw
const v  = Math.max(-1, Math.min(1, vel / 40)); // normalise to −1..1

// 2. Shear + smear — GLSL: offset y by column position × velocity
float shear = v * (uv.x - 0.5) * 0.65;  // lean proportional to x
float y     = uv.y * (1.0 + abs(v)*0.10) - abs(v)*0.05; // y-stretch

// 3. Chromatic split — R/G/B at different y offsets
vec2 uvG = vec2(uv.x, y + shear);
vec2 uvR = vec2(uv.x, y + shear + v * 0.038);   // red leads
vec2 uvB = vec2(uv.x, y + shear - v * 0.038);   // blue lags
gl_FragColor = vec4(
  texture2D(u_image, uvR).r,
  texture2D(u_image, uvG).g,
  texture2D(u_image, uvB).b, 1.0);

A Svelte action, <canvas use:scrollShear>, driving a single WebGL fragment pass. Texture managed outside glQuad, velocity tracked in the RAF loop. DPR-aware, pauses off-screen, accessible under reduced-motion.