Introduction
This component reads as a tall ribbon of photographs floating in the middle of the screen, gently bending left and right as it scrolls forever, but there is no DOM list of images and no scroll container. There is one flat canvas with every photo drawn onto it once, one WebGL plane the size of the viewport, and one fragment shader that decides, for every pixel, whether it belongs to the ribbon and which part of the baked canvas it should sample. The wave itself is two sine waves added together and multiplied by a distance-from-scroll offset — nothing more elaborate than that.
The illusion depends on keeping three responsibilities apart: the canvas band is built once from the loaded photos and never redrawn per frame — it is pure image composition. The shader never knows how many photos exist or what order they are in; it only knows a scroll offset and a texture to sample. And the input layer — wheel, drag, touch, keyboard — never touches WebGL at all, it only ever nudges one number, the target scroll position, and lets inertia carry it the rest of the way.
How to bake a tall sequence of differently-sized photos into one seamless canvas texture — how to turn that texture into a wandering ribbon using a fragment shader that adds two sine waves at different frequencies — how to fake an endless loop by re-drawing the first photo after the last one instead of looping the texture coordinate — and how to drive one scroll variable from wheel, mouse drag, touch drag, and arrow keys, then let it coast on inertia after the input stops.
Step 1 — Composing Every Photo Onto One Tall Canvas
A WebGL fragment shader can sample a texture cheaply per pixel, but it cannot lay out a list of differently-sized images for you. So before any shader runs, every loaded photo is drawn top-to-bottom onto a single offscreen 2D canvas, scaled to a fixed band width and spaced apart by a fixed gap. The canvas ends up as tall as the sum of every scaled photo plus its gap — effectively one long filmstrip that becomes the texture the shader will scroll through.
let y = 0;
images.forEach(info => {
const h = Math.round(IMAGE_WIDTH * (info.img.height / info.img.width));
const x = (BAND_WIDTH - IMAGE_WIDTH) / 2;
ctx.drawImage(info.img, x, y, IMAGE_WIDTH, h);
y += h + GAP;
});Because every photo is drawn at its own natural aspect ratio instead of being force-cropped to a square, a portrait shot and a landscape shot sit in the band at different heights without ever distorting — the layout math simply accumulates whatever height each image actually scales to.
Step 2 — Bending a Straight Band Into a Wandering Ribbon
The plane the shader paints onto is a flat rectangle covering the whole screen. For every pixel, the shader first works out where the band's left and right edges would sit if it were perfectly straight, then offsets that edge sideways by a wave calculated from the pixel's vertical position plus the current scroll. Two sine waves at different frequencies and amplitudes are added together so the curve never repeats in a perfectly predictable rhythm, then that combined offset is scaled down near the top and bottom of the screen so the ribbon relaxes toward straight at the edges instead of curving off into nothing.
float waveY = pixelCoord.y + uScroll * uSpeed;
float wave1 = sin(waveY * uFrequency) * uIntensity;
float wave2 = sin(waveY * uFrequency * 2.3 + 1.3) * uIntensity * 0.35;
float offset = wave1 + wave2;
float centerWeight = smoothstep(0.0, 1.0, 1.0 - abs(vUv.y - 0.5) * 2.0);
float bandLeft = bandLeftBase + offset * centerWeight;A single sine wave reads as a mechanical wobble the moment you watch it for more than a few seconds — the second, smaller wave at a different frequency is what keeps the eye from ever predicting the next bend.
Sampling outside the band should not produce a jagged pixel-stair edge. Two smoothstep calls, one for the left edge and one for the right, turn the boundary into a soft alpha ramp a couple of pixels wide, so the photo band fades into the transparent background instead of ending abruptly.
Step 3 — One Number, Four Input Sources, and a Coast-to-Stop
Every input method — mouse wheel, mouse drag, touch drag, and the up/down arrow keys — writes to the exact same target scroll variable instead of maintaining its own state. Wheel and arrow presses add a fixed delta; dragging adds the pointer's frame-to-frame movement. None of them move the actual scroll position directly — they only move the target, and every animation frame eases the live scroll a fraction of the way toward that target, which is what keeps the motion soft instead of jumpy.
function applyInertia() {
if (!isDragging) {
targetScrollY += scrollVelocity;
scrollVelocity *= 0.92;
if (Math.abs(scrollVelocity) < 0.5) scrollVelocity = 0;
}
}
function animate() {
requestAnimationFrame(animate);
applyInertia();
scrollY += (targetScrollY - scrollY) * (isDragging ? 0.3 : 0.1);
material.uniforms.uScroll.value = scrollY;
renderer.render(scene, camera);
}The moment a drag begins, scrollVelocity is zeroed out so a fast flick that was still coasting from a previous drag does not fight the new one. Velocity is only ever recalculated from the pointer's own movement while dragging, then handed back to the inertia loop the instant the pointer lifts.
Step 4 — Making an Endless Band Feel Endless
The canvas texture is not infinite — it is one finite filmstrip. The illusion of an endless scroll comes from two things working together: the texture's wrap mode is set to repeat, so sampling past the bottom edge simply starts again at the top, and the very first photo is drawn a second time immediately after the last one during the baking step. That second copy means the seam where the texture wraps lands on a repeated image instead of an abrupt cut from the last photo straight back to the first.
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
// after the main loop, draw image[0] again at the bottom
ctx.drawImage(images[0].img, x, sequenceHeight, IMAGE_WIDTH, firstHeight);Every photo also has a same-size fallback: if a URL fails to load, an onerror handler draws a flat hue-shifted gradient of the same dimensions into that slot instead of leaving a gap. The band's height math never has to branch on whether a given photo actually arrived, because the fallback always fills the space it was expected to.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| bandWidth | 260px | How wide the photo ribbon is; wider bands need a larger curvature intensity to still read as bending |
| curvatureIntensity | 55.0 | Maximum sideways offset of the band at the peak of the wave, in pixels |
| curvatureFrequency | 0.0016 | How tightly packed the waves are along the band; lower values produce long, lazy curves |
| speed | 1.6 | Multiplier on scroll offset before it feeds the wave — higher values make the curve travel faster than the images scroll |
| inertia | 0.92 | Per-frame velocity decay after input stops; closer to 1 coasts longer, closer to 0 stops almost immediately |
| edgeSoftness | 2.0px | Width of the smoothstep ramp at the band edges, keeping the silhouette antialiased instead of jagged |
Full Source Code
The complete piece is a single self-contained HTML document — the canvas band baking, the Three.js scene and orthographic camera, the dual-sine wave shader, and the wheel/drag/touch/keyboard input with inertia, and nothing else. No build step, no external assets beyond the photos themselves, no animation library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/image-wave-scroll.html