A horizontal gallery you move with the wheel or by dragging. When it moves fast, the images bend towards you and away from you, and the bend follows the speed: a slow scroll barely moves them, a flick folds the whole row. When the gallery stops, they settle flat again.
The shader is short. Most of the work is around it: keeping the DOM in charge of the scroll, turning that scroll into one number the shader can use, and making the result hold up on a phone.
We’ll build it in four steps, one per branch of the repo:
| Branch | What it shows |
|---|---|
main |
The gallery itself, no motion |
dom |
The wheel and drag move the gallery, smoothed with a lerp |
webgl |
Same scroll, the images are now WebGL planes that bend with the speed |
mobile |
What changes for phones: textures, pixel ratio, wave length, pausing |
If you only need a horizontal gallery, stop at step 2. It’s a few dozen lines, no WebGL, and it works everywhere.
The repo is vanilla TypeScript. Where the code depends on the framework, the tabs also show it in Vue. Your choice applies to the whole page.
1. The gallery
A track of figures inside a full-screen container. Nothing here knows about the scroll or the wave:
<main class="gallery">
<div class="gallery__track" data-track>
<figure class="gallery__media">
<img
class="gallery__image"
src="/images/project-1.webp"
alt=""
draggable="false"
/>
</figure>
<!-- ... -->
</div>
</main>
Every size is in rem, and the root font size follows the screen width:
html {
font-size: calc(100vw / 1920 * 10);
}
:root {
--media-width: 36rem;
--media-height: 48rem;
--gap: 10.6rem;
}
@media (max-width: 767px) {
:root {
--media-width: 114rem;
--media-height: 152rem;
}
}
1rem is 10px on a 1920px screen and scales with it, so the gallery keeps its proportions from a laptop to a large monitor. Under 768px, an image takes 59% of the screen width instead of 19%: 232×309px on a 390px phone.
The track’s padding frames the first and last images:
.gallery__track {
display: flex;
flex: none;
gap: var(--gap);
padding-inline: calc(50vw - var(--media-width) / 2);
}
The first image starts centered, the last one ends centered. It also makes the scroll range easy to compute: the width of the track minus the width of the screen.
The boxes are 3:4, like the images. That will matter in step 5: the shader maps each texture onto its plane without cropping, so an image with another ratio would stretch.
2. The scroll
The gallery doesn’t scroll natively. Two numbers drive it: target, where the input wants the gallery to be, and current, where it is. On every frame, current covers a tenth of the distance left to target:
private update = () => {
this.target = gsap.utils.clamp(-this.limit, 0, this.target);
this.current = damp(this.current, this.target, SCROLL_LERP);
this.track.style.transform = `translate3d(${this.current}px, 0, 0)`;
};
Both go from 0, first image centered, to -limit, last image centered. limit is the track’s width minus the screen’s, measured on resize.
damp is a lerp that doesn’t depend on the refresh rate:
// 1 at 60Hz, 0.5 at 120Hz. Capped so a stalled frame can't make things jump.
export const frameRatio = () => Math.min(gsap.ticker.deltaRatio(60), 4);
export const damp = (
current: number,
target: number,
amount: number,
ratio = frameRatio(),
) => gsap.utils.interpolate(current, target, 1 - Math.pow(1 - amount, ratio));
SCROLL_LERP is 0.1, tuned at 60Hz. At 120Hz, frameRatio() is 0.5 and each frame covers 5.1% of the distance instead of 10%, so the gallery takes the same time to settle on both screens. With a plain lerp, it would settle twice as fast on a 120Hz phone as on a 60Hz laptop.
The wheel
private onWheel = (e: WheelEvent) => {
const delta = e.deltaMode === 1 ? e.deltaY * LINE_HEIGHT : e.deltaY;
const target = gsap.utils.clamp(-this.limit, 0, this.target - delta * WHEEL_FACTOR);
// At either end, the wheel goes back to the page
if (target === this.target) return;
e.preventDefault();
this.target = target;
};
- The vertical wheel moves the gallery horizontally.
WHEEL_FACTORis0.8: a 100px wheel step moves the target by 80px. - Firefox with a mouse wheel counts in lines, not pixels (
deltaMode1). Multiplying by16brings it back to pixels. - The listener isn’t passive, so it can call
preventDefaultand keep the page still. It only does it while the gallery can still move: at either end, the wheel scrolls the page again. That’s what lets the demo above sit in the middle of a long article without trapping you.
Dragging
Pointer events cover the mouse and touch with one set of listeners:
private onPointerDown = (e: PointerEvent) => {
this.dragging = true;
this.dragStart = e.clientX;
this.dragFrom = this.current;
};
private onPointerMove = (e: PointerEvent) => {
if (!this.dragging) return;
this.target = this.dragFrom - (this.dragStart - e.clientX) * DRAG_FACTOR;
};
The drag starts from current, not target. Grab the gallery while it’s still gliding and it stops under your finger, instead of jumping to where it was headed.
One line of CSS makes it work on touch screens:
.gallery {
touch-action: pan-y;
}
The browser keeps vertical swipes for the page and hands horizontal ones to the listeners. Without it, the browser can claim the gesture for its own scrolling and cancel the pointer.
Wiring it up
constructor(track: HTMLElement) {
this.track = track;
this.resize();
window.addEventListener("resize", this.resize);
window.addEventListener("wheel", this.onWheel, { passive: false });
window.addEventListener("pointerdown", this.onPointerDown);
window.addEventListener("pointermove", this.onPointerMove);
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointercancel", this.onPointerUp);
gsap.ticker.add(this.update);
}// useHorizontalScroll.ts
import gsap from 'gsap';
import { onBeforeUnmount, onMounted, type Ref } from 'vue';
import { damp } from './utils';
const WHEEL_FACTOR = 0.8;
const DRAG_FACTOR = 0.8;
const SCROLL_LERP = 0.1;
const LINE_HEIGHT = 16;
export function useHorizontalScroll(track: Ref<HTMLElement | undefined>) {
// Not reactive on purpose: it changes every frame and the template never reads it
const scroll = { current: 0, target: 0 };
let limit = 0;
let dragging = false;
let dragStart = 0;
let dragFrom = 0;
const resize = () => {
limit = Math.max((track.value?.offsetWidth ?? 0) - window.innerWidth, 0);
};
const onWheel = (e: WheelEvent) => {
const delta = e.deltaMode === 1 ? e.deltaY * LINE_HEIGHT : e.deltaY;
const target = gsap.utils.clamp(
-limit,
0,
scroll.target - delta * WHEEL_FACTOR,
);
// At either end, the wheel goes back to the page
if (target === scroll.target) return;
e.preventDefault();
scroll.target = target;
};
const onPointerDown = (e: PointerEvent) => {
dragging = true;
dragStart = e.clientX;
dragFrom = scroll.current;
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return;
scroll.target = dragFrom - (dragStart - e.clientX) * DRAG_FACTOR;
};
const onPointerUp = () => {
dragging = false;
};
const update = () => {
scroll.target = gsap.utils.clamp(-limit, 0, scroll.target);
scroll.current = damp(scroll.current, scroll.target, SCROLL_LERP);
if (track.value)
track.value.style.transform = `translate3d(${scroll.current}px, 0, 0)`;
};
onMounted(() => {
resize();
window.addEventListener('resize', resize);
window.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
gsap.ticker.add(update);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resize);
window.removeEventListener('wheel', onWheel);
window.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
gsap.ticker.remove(update);
});
return scroll;
}<!-- Gallery.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useHorizontalScroll } from './useHorizontalScroll';
defineProps<{ slides: string[] }>();
const track = ref<HTMLElement>();
useHorizontalScroll(track);
</script>
<template>
<main class="gallery">
<div ref="track" class="gallery__track">
<figure v-for="(src, i) in slides" :key="i" class="gallery__media">
<img class="gallery__image" :src="src" alt="" draggable="false" />
</figure>
</div>
</main>
</template>scroll stays a plain object. Making it a ref would trigger Vue’s reactivity on every frame for values nothing in the template displays.
The listeners are on window: a drag that leaves the gallery keeps going, and the wheel works wherever the cursor is.
3. Why WebGL
The DOM can move, scale, rotate and skew an image. It can’t bend it: CSS transforms keep straight lines straight, and a perspective on the track only tilts the images like flat cards. To fold an image, you need to move points inside it, and that means a mesh with vertices you control.
So the webgl branch keeps the same gallery and the same scroll, with three changes:
- The DOM stays in charge. The scroll still moves the track. Each plane copies the position of its figure.
- The speed becomes a number. The scroll exposes how far the gallery still has to go.
- That number bends the planes. The vertex shader pushes the vertices towards the camera along a sine.
Run git diff dom webgl in the repo to see exactly what changes.
4. The DOM stays in charge
The figures stay in the page. They’re hidden, but still laid out, and the track still moves them:
.gallery__media {
visibility: hidden;
}
Unlike display: none, visibility: hidden keeps the box. The planes need those boxes to know where to be.
Each plane measures its figure once per resize:
resize(scroll: number) {
const rect = this.element.getBoundingClientRect();
// The gallery is translated by the scroll at this point
this.left = rect.left - scroll;
this.mesh.scale.set(
(rect.width / this.screen.x) * this.viewport.x,
(rect.height / this.screen.y) * this.viewport.y,
1,
);
this.mesh.position.y =
this.viewport.y / 2 - this.mesh.scale.y / 2 - (rect.top / this.screen.y) * this.viewport.y;
}
this.left is where the figure sits with the gallery at 0. Measured while the track is translated, the rect includes the scroll, hence the - scroll.
On every frame, the plane only adds the scroll to that number:
const left = (this.left + scroll) / this.screen.x;
this.mesh.position.x =
-this.viewport.x / 2 + this.mesh.scale.x / 2 + left * this.viewport.x;
Pixels to scene units
The wave needs depth, so the camera is a perspective one, at z = 5 with a 45° field of view. The planes sit at z = 0. viewport is what the camera sees at that depth, in scene units:
const fov = (this.camera.fov * Math.PI) / 180;
const viewportHeight = 2 * Math.tan(fov / 2) * this.camera.position.z;
this.viewport.set(viewportHeight * this.camera.aspect, viewportHeight);
With these values, the screen is 4.14 units tall at z = 0, whatever its size in pixels. The conversions above are rules of three between screen (pixels) and viewport (units), plus the change of origin: the DOM counts from the top left with y going down, the scene from the center with y going up.
A plane at z = 0 covers its figure exactly. Anything pushed towards the camera looks bigger. That’s what the wave will use.
Wiring it up
The WebGL side only reads two numbers from the scroll, on every frame:
export interface ScrollState {
readonly current: number;
readonly speed: number;
}
renderGallery(track, slides);
const scroll = new HorizontalScroll(track);
new GalleryGL(scroll, [
...track.querySelectorAll<HTMLElement>('.gallery__media'),
]);<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useHorizontalScroll } from './useHorizontalScroll';
import { GalleryGL } from './webgl/GalleryGL';
defineProps<{ slides: string[] }>();
const track = ref<HTMLElement>();
// Filled by index: a ref array on a v-for doesn't guarantee the order
const figures: HTMLElement[] = [];
// Called first, so its ticker callback runs before the render's
const scroll = useHorizontalScroll(track);
let gl: GalleryGL;
onMounted(() => {
gl = new GalleryGL(scroll, figures);
});
// The destroy() from step 7
onBeforeUnmount(() => gl.destroy());
</script>
<template>
<main class="gallery">
<div ref="track" class="gallery__track">
<figure
v-for="(src, i) in slides"
:key="i"
:ref="
el => {
if (el) figures[i] = el as HTMLElement;
}
"
class="gallery__media"
>
<img class="gallery__image" :src="src" alt="" draggable="false" />
</figure>
</div>
</main>
</template>useHorizontalScroll is the composable from step 2, with the speed from step 5 added to scroll. GalleryGL stays a plain class: it reads two numbers and a list of elements, so nothing in it knows about Vue.
Both the scroll and the render are on GSAP’s ticker, the scroll first. On each frame the track moves, then the planes read the same current: the canvas never lags a frame behind the DOM.
Eight slides share four files. GalleryGL keeps one texture per file, so each image is downloaded and uploaded to the GPU once:
const load = (src: string) => {
const texture = textures.get(src) ?? loader.loadAsync(src);
textures.set(src, texture);
return texture;
};
5. The speed becomes the wave
A number for the speed
this.speed = damp(
this.speed,
(this.target - this.current) * SPEED_FACTOR,
SPEED_LERP,
ratio,
);
The speed is the gap between target and current: how far the gallery still has to go. Scroll slowly and current keeps up, the gap stays small. Flick the wheel and target jumps ahead while current takes a few frames to follow.
SPEED_FACTOR is 0.0018: a 500px gap gives 0.9. The second damp, also at 0.1, smooths that value, so the wave grows and fades instead of following each wheel step.
The shader
vec4 viewPosition = modelViewMatrix * vec4(p, 1.0);
viewPosition.z += sin(viewPosition.x / uViewportSizes.x * uFrequency / 2.0) * abs(uStrength);
gl_Position = projectionMatrix * viewPosition;
uStrength is the speed, set on every frame.
viewPositionis the vertex once the plane is placed, in camera space. Itsxis its position on screen, in units, from-viewport / 2on the left edge toviewport / 2on the right.- Divided by the viewport width, it goes from
-0.5to0.5across the screen, whatever the screen. uFrequencysets how much of a sine fits across the screen. It’sπ² × 1.3, halved in the shader: 6.4 radians, a little over one period. Vertices in the left half go away from the camera, vertices in the right half come towards it, and the center and the edges don’t move.abs(uStrength)sets the amplitude. The sign of the speed is the direction of the scroll, and the shape stays the same both ways.
The shape is fixed to the screen. What moves is the images: as the gallery slides, each one crosses the sine, and its vertices rise and fall as they go through. That’s what reads as a wave.
Those 0.9 units from a 500px gap are a lot at this scale. The camera is 5 units away, so a vertex pushed 0.9 towards it looks 1.22 times bigger (5 / 4.1).
Vertices to move
private readonly geometry = new THREE.PlaneGeometry(1, 1, 50, 100);
A plane with a single segment has four corners: push them in z and the image tilts, it doesn’t bend. The 50 columns give the sine enough points to read as a curve. One geometry serves all eight planes: they only differ by their position, scale and uniforms.
The ripple
vec3 p = position;
p.z = (sin(p.x * 4.0 + uTime) * 0.5 + cos(p.y * 2.0 + uTime) * 0.5) * 0.1;
Before the wave, each plane gets a slow ripple in its own space, driven by time. It reaches 0.1 units at most, about 2% of the plane’s size at this distance. It keeps the gallery from looking frozen when nobody scrolls. It also means the canvas has something new to draw on every frame, which matters in the next step.
uTime grows by 0.02 per frame at 60Hz, scaled by frameRatio() like the lerps.
The fade
The planes start transparent and fade in over 0.5s once their texture has loaded. The fragment shader multiplies the color by the alpha:
gl_FragColor = vec4(color.rgb * uAlpha, uAlpha);
With premultipliedAlpha: true on the material, the image fades out of the background cleanly, without dark edges.
6. Phones
On the gallery this effect comes from, one thing is tuned for phones: the length of the wave. Its images are small too, but for every device. The mobile branch keeps the wave length, brings the demo’s images down to that size, and adds two improvements the original doesn’t have: a lower pixel ratio on phones, and a pause when the canvas is off screen. Run git diff webgl mobile: about twenty lines of code, and four images.
The wave length
// About one sine period on a desktop, five eighths of one on a phone
const WAVE_FREQUENCY = isPhone ? Math.PI * 2.5 : Math.PI * Math.PI * 1.3;
This is the phone tuning from the original. On a desktop, an image covers 19% of the screen width, so it spans a fifth of the period: a gentle curve. On a phone it covers 59%. With the desktop frequency, it would span 0.6 of a period and fold into an S, and two neighbouring images would bend in opposite directions. With 2.5π, the whole screen holds five eighths of a period, and one image spans 0.37 of it: a single curve.
Lighter textures
Until now, the demo’s images were full size: two of them are 3525×4700 and 3728×4971 pixels. As files, the four weigh 455KB. But files are compressed, textures aren’t. The GPU stores 4 bytes per pixel, plus a third for the mipmaps:
| Branch | Files | GPU memory |
|---|---|---|
webgl |
455KB | about 199MB |
mobile |
110KB | about 10MB |
The files shrink by 4, the GPU memory by 19. The mobile branch resizes the four images to 600×800, the size the original gallery uses on every device: about 1.7 times what they’re drawn at on a 390px phone at 1.5×. Each texture is also uploaded to the GPU the first time it’s drawn, and that upload blocks the main thread: the bigger the image, the longer that frame.
This change is in the files, not in the code. On a real site, ask your image CDN for the size each device needs.
The pixel ratio
Proposed for the demo: the original caps the pixel ratio at 2 on every device.
// Every pixel runs the fragment shader: on a phone, 1.5 instead of 2 draws 44% fewer
const MAX_PIXEL_RATIO = isPhone ? 1.5 : 2;
The canvas covers the whole screen, and each of its pixels runs the fragment shader. A 390×844 phone at 3× is capped to 2 in the webgl branch: 1.3 million pixels per frame. At 1.5, it’s 740,000.
For comparison, the eight planes have about 41,000 vertices in total. Here, the pixels cost more than the geometry, so the grid stays as it is.
Pausing off screen
Proposed for the demo: the original draws on every frame, on screen or not.
// Off screen, nothing to draw: the canvas stops until it comes back into view
new IntersectionObserver(([entry]) => {
this.visible = entry?.isIntersecting ?? true;
}).observe(this.renderer.domElement);
And at the top of the render:
if (!this.visible) return;
Because of the ripple, the canvas never runs out of things to draw. When the gallery is one section of a longer page, it would keep the GPU busy while you read something else. Inside an iframe, the observer measures against the visible part of the top page, so the demo at the top of this article stops drawing once you’ve scrolled past it.
Which device is a phone
// A touch screen whose shortest side is under 768px: a phone, even in landscape
export const isPhone =
window.matchMedia('(pointer: coarse)').matches &&
Math.min(window.screen.width, window.screen.height) < 768;
The shortest side keeps a phone in landscape a phone. It’s read once, on load: a desktop window narrowed to 390px gets the phone layout from the CSS, but keeps the desktop pixel ratio and wave.
7. Going to production
The demo is built to be read. Before shipping it on a client site, there’s more to handle:
- A
destroy()method. Remove the window listeners, take both callbacks off the ticker, disconnect the observer, dispose of the geometry, materials, textures and renderer. Without it, page transitions leak a WebGL context every time. - Telling a drag from a click. On a real gallery, the images are links. Compare the pointer’s position on down and up, and cancel the click past a few pixels.
- The keyboard. Wheel and drag aren’t the only ways in. The arrow keys, and a link receiving focus, should move the target too.
prefers-reduced-motion. Keep the scroll, drop the motion on top of it: set the strength to0and stop the ripple.- A fallback. If WebGL fails to start, remove
visibility: hiddenfrom the figures: thedombranch works on its own. - One texture size per device. Pick the image size from the screen and the pixel ratio, instead of one file for everyone.
The parallax version
This tutorial is about the wave. The parallax, where each image slides inside its frame as the gallery moves, has its own tutorial on Codrops: Creating a Smooth Horizontal Parallax Gallery: From DOM to WebGL.
Source
The full code is on GitHub, one branch per step. Start from main and build it yourself, or jump straight to mobile.
git clone https://github.com/davidfaure/horizontal-gallery-wave
cd horizontal-gallery-wave
npm install
git checkout mobile
npm run dev