Aduok Code

An Image Wave Scroll, Built from a Baked Canvas Band, One Dual-Sine Distortion Shader, and Inertia-Driven Scroll Input

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.

What you will learn

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.

01
Part One
Baking the Image Band Onto a Canvas Texture

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.

JS
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.

02
Part Two
A Dual-Sine Wave Distortion Shader

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.

GLSL
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.

Why the edges use smoothstep, not a hard cutoff

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.

03
Part Three
Wheel, Drag, Touch, and Inertia

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.

JS
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);
}
Why velocity resets on drag start

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.

04
Part Four
The Seam, the Loop, and the Fallback

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.

JS
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

PropertyExampleEffect
bandWidth260pxHow wide the photo ribbon is; wider bands need a larger curvature intensity to still read as bending
curvatureIntensity55.0Maximum sideways offset of the band at the peak of the wave, in pixels
curvatureFrequency0.0016How tightly packed the waves are along the band; lower values produce long, lazy curves
speed1.6Multiplier on scroll offset before it feeds the wave — higher values make the curve travel faster than the images scroll
inertia0.92Per-frame velocity decay after input stops; closer to 1 coasts longer, closer to 0 stops almost immediately
edgeSoftness2.0pxWidth 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.

HTML — image-wave-scroll.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/image-wave-scroll.html
Want to try it yourself?Open the live Image Wave Scroll file