3D Spiral Image Gallery
Goal
Build a single scrollable hero where 75 curved image tiles wind down a five-revolution 3D helix, rendered in WebGL. Ten editorial photos are cycled across the tiles, a custom shader dims each tile as it turns away from the camera (depth shading around the spiral), the whole spiral slowly auto-rotates and gets spun by scroll velocity (with inertial decay), and scroll progress lerps the camera downward through the helix so you appear to descend the spiral. On desktop, mouse position adds a subtle X/Z parallax tilt to the whole spiral. Smooth scroll via Lenis. After the tall hero comes a short second section.
Tech
Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). Use three for all WebGL and lenis for smooth scroll. No GSAP, no ScrollTrigger — all motion is a hand-rolled requestAnimationFrame loop with manual lerp/inertia. Put the two GLSL shader strings in a separate shaders.js and import them. Instantiate Lenis once with new Lenis({ autoRaf: true }) (it drives its own rAF; you still run your own render loop separately).
Layout / HTML
Two stacked sections; the WebGL canvas is injected into the first by JS.
<section class="hero">
<h1>Somewhere between structure and disorder new forms quietly start to emerge</h1>
</section>
<section class="about">
<h3>New forms begin here</h3>
</section>
<script type="module" src="./script.js"></script>
The JS queries .hero, creates the renderer, and appends renderer.domElement into .hero. Copy text is neutral editorial filler — no brand names.
Styling
Font — a tight uppercase grotesk. Load Hanken Grotesk from Google Fonts via @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap") with sans-serif fallback; any neutral grotesk of that width is fine.
* { margin:0; padding:0; box-sizing:border-box }body { font-family:"Hanken Grotesk", sans-serif }h1, h3 { text-transform:uppercase; letter-spacing:-0.1rem; line-height:0.8 }h1 { font-size:clamp(3.5rem, 10vw, 15rem) }(massive, wall-of-text)h3 { font-size:clamp(2.5rem, 5vw, 7.5rem) }section { position:relative; width:100%; padding:2rem; color:#d2d2d2; overflow:hidden }.hero { height:150svh; background:#242424; text-align:justify }— the extra height (1.5 viewports) is the scroll runway that drives the camera descent;text-align:justifyspreads the H1 edge to edge..about { height:100svh; background:#171717; display:flex; justify-content:center; align-items:center; text-align:center }canvas { position:absolute; top:0; left:0; width:100%; height:100% }— fills the hero, sits under the H1.@media (max-width:1000px) { .hero { height:125svh } }
The effect (be exhaustive — Three.js helix + shader + interaction)
Config (module constant CONFIG)
const CONFIG = {
totalImages: 10,
tilesPerRevolution: 15,
revolutions: 5,
startRadius: 5,
endRadius: 3.5,
tileHeightRatio: 1.1,
tileSegments: 24,
spiralGap: 0.35,
tileOverlap: 0.005,
cameraZ: 12,
cameraSmoothing: 0.075,
baseRotationSpeed: 0.001,
scrollRotationMultiplier: 0.0035,
rotationDecay: 0.9,
scrollMultiplier: 1.25,
cameraYMultiplier: 0.2,
parallaxStrength: 0.1,
};
Derived: totalTiles = Math.floor(tilesPerRevolution * revolutions) = 75; angleStep = (Math.PI*2) / tilesPerRevolution (24° per tile → 15 tiles per full turn).
Scene / renderer / camera
scene = new THREE.Scene().camera = new THREE.PerspectiveCamera(75, hero.clientWidth / hero.clientHeight, 0.1, 1000);camera.position.z = CONFIG.cameraZ(12). Aspect is tall because the hero is 150svh.renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));renderer.setSize(hero.clientWidth, hero.clientHeight); appendrenderer.domElementto.hero.- Shared uniform object:
const cameraPositionUniform = { value: new THREE.Vector3(0, 0, CONFIG.cameraZ) }— every tile material references this same object, updated each frame.
Textures
Load 10 images with THREE.TextureLoader from /c/studiodialect-image-gallery/img${i+1}.jpg (i = 0..9). In each load callback set t.minFilter = THREE.LinearMipmapLinearFilter and t.anisotropy = renderer.capabilities.getMaxAnisotropy(). Store in a textures array.
Precompute the vertical edges of every tile (tileEdgesY)
Start const tileEdgesY = [0];. For i in 0..totalTiles-1:
progress = i / totalTiles;
radius = startRadius + (endRadius - startRadius) * progress; // 5 → 3.5 (radius tapers inward as you descend)
arcWidth = (2*Math.PI*radius) / tilesPerRevolution;
tileHeight= arcWidth * tileHeightRatio; // 1.1× → slightly taller than wide
tileEdgesY.push(tileEdgesY[i] - (tileHeight + spiralGap) / tilesPerRevolution);
This yields a monotonically decreasing array of Y break-points; consecutive tiles step down by a fraction of (tileHeight + gap), so a full revolution (15 tiles) drops by roughly one tile-height + gap.
Build the spiral (custom curved geometry per tile)
const spiral = new THREE.Group(); scene.add(spiral); Then for each i in 0..totalTiles-1:
- Recompute
progress, radius, arcWidth, tileHeightas above. tileAngle = arcWidth/radius + tileOverlap(angular width of the panel, plus a hair of overlap to hide seams).centerY = (tileEdgesY[i] + tileEdgesY[i+1]) / 2(vertical center of this tile).slope = tileEdgesY[i+1] - tileEdgesY[i](negative — the amount the tile must shear downward across its width so its edges meet the neighbours into a continuous ramp).
Build a BufferGeometry by hand — a 2-row × (tileSegments+1)-column strip (segments = 24 → 25 columns). Loop row 0→1, col 0→segments:
angle = (col/segments - 0.5) * tileAngle;
position = [ Math.sin(angle)*radius,
(row-0.5)*tileHeight + (col/segments - 0.5)*slope,
Math.cos(angle)*radius ];
uv = [ col/segments, row ];
So each tile is a curved arc at distance radius from the Y axis (x/z from sin/cos), spanning tileHeight vertically, sheared by slope across its width so it leans down the helix. Indices: for each col 0..segments-1, two triangles (current, below, current+1) and (below, below+1, current+1) where below = current + segments + 1. Call computeVertexNormals().
Material (per tile):
new THREE.ShaderMaterial({
vertexShader, fragmentShader,
uniforms: {
uMap: { value: textures[i % CONFIG.totalImages] }, // photos cycle every 10 tiles
uCameraPosition: cameraPositionUniform, // shared object
},
side: THREE.DoubleSide,
});
Then: mesh.position.y = centerY; wrap it in a per-tile THREE.Group whose rotation.y = i * angleStep; add the mesh to that group and the group to spiral. The Y-rotation per tile (24°) plus the descending centerY is what coils the 75 tiles into a 5-turn helix.
Finally: const spiralHeight = Math.abs(tileEdgesY[totalTiles]); — total vertical span of the helix, used to scale the camera descent.
Shaders (shaders.js)
Vertex — pass through UV and world-space normal/position:
varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
Fragment — sample the photo and dim tiles facing away from the camera:
uniform sampler2D uMap;
uniform vec3 uCameraPosition;
varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
void main() {
vec4 tex = texture2D(uMap, vUv);
vec3 viewDir = normalize(uCameraPosition - vWorldPosition);
float facing = max(dot(-normalize(vWorldNormal), viewDir), 0.0);
float falloff = smoothstep(-0.2, 0.5, facing) * 0.45 + 0.42; // ~0.42 (back) → ~0.87 (front)
vec3 color = mix(vec3(1.0), tex.rgb * falloff, 0.975) * 1.25; // slight white lift, then ×1.25 brighten
gl_FragColor = vec4(color, tex.a);
}
Net look: tiles on the far side of the spiral read noticeably darker; tiles facing the camera are bright and slightly washed/lifted. The -normalize(vWorldNormal) inverts the normal so DoubleSide back-faces still shade correctly.
Input state
let scrollY = 0, spinVelocity = 0;lenis.on("scroll", (e) => { scrollY = window.pageYOffset; spinVelocity = e.velocity * CONFIG.scrollRotationMultiplier; })— Lenis reports scroll velocity; multiply by 0.0035 to convert it into an angular kick.- Mouse (range −1..1): on
windowmousemove,mouseX = (e.clientX/window.innerWidth - 0.5)*2,mouseY = (e.clientY/window.innerHeight - 0.5)*2. Keep smoothed copiessmoothX, smoothY(start 0). let isMobile = window.innerWidth < 1000;
Render loop (requestAnimationFrame, the star)
function animate() {
requestAnimationFrame(animate);
// 1. Scroll progress → camera descent (eased)
const progress = Math.min(scrollY / (window.innerHeight * CONFIG.scrollMultiplier), 1); // 0→1 over 1.25 viewports
camera.position.y +=
(-(progress * spiralHeight * CONFIG.cameraYMultiplier) - camera.position.y)
* CONFIG.cameraSmoothing; // lerp toward −(progress·spiralHeight·0.2), factor 0.075
// 2. Desktop-only mouse parallax tilt on the whole spiral
if (!isMobile) {
smoothX += (mouseX - smoothX) * 0.02; // slow 0.02 follow
smoothY += (mouseY - smoothY) * 0.02;
spiral.rotation.x = smoothY * CONFIG.parallaxStrength; // pitch ±0.1 rad
spiral.rotation.z = -smoothX * CONFIG.parallaxStrength * 0.3; // roll ±0.03 rad
}
// 3. Feed camera position to the shader (drives the facing/dimming)
cameraPositionUniform.value.copy(camera.position);
// 4. Continuous spin + scroll-velocity kick with inertial decay
spiral.rotation.y += CONFIG.baseRotationSpeed + spinVelocity; // always drifts at 0.001 rad/frame
spinVelocity *= CONFIG.rotationDecay; // 0.9 decay → spins settle after scroll stops
renderer.render(scene, camera);
}
animate();
Key feel: the spiral always rotates slowly (baseRotationSpeed 0.001); scrolling injects extra angular velocity proportional to scroll speed, which decays by ×0.9 each frame so a flick keeps spinning and coasts to a stop. Camera Y eases (0.075) toward a target proportional to scroll progress × total helix height × 0.2, so scrolling walks the camera down the spiral. Mouse tilt is a separate, very slow (0.02) parallax overlay applied only on desktop.
Resize
On window resize: recompute isMobile = window.innerWidth < 1000; camera.aspect = hero.clientWidth / hero.clientHeight; camera.position.z = isMobile ? 15 : CONFIG.cameraZ (pull the camera back on mobile); camera.updateProjectionMatrix(); renderer.setSize(hero.clientWidth, hero.clientHeight).
Assets / images
10 dreamlike, high-fashion editorial photos, all TALL PORTRAIT orientation (~2:3, roughly 960×1440) — the texture is stretched across each curved panel so exact ratio is flexible. They cycle across the 75 tiles (textures[i % 10]), so each photo appears ~7–8 times around the helix. The set is deliberately mixed, not one single mood: it swings between three registers — (a) soft, hazy, desaturated color scenes (misty whites, muted greens, warm faded daylight) built around pale, white/platinum-haired figures in ceremonial white dress; (b) bold saturated studio color (deep crimson against electric cobalt blue); and (c) stark, high-contrast black-and-white studio portraits. Surreal fashion-editorial feel; the contrast between the airy white/green scenes and the punchy studio shots is part of the look. Representative roles by form and content (no brands):
- Profile-ish shot of a blindfolded bald young figure with a bright red satin ribbon over the eyes, wearing a ruffled white lace collar and loose white shirt, standing under gothic stone arches draped in ivy and small white blossoms; soft green-and-grey daylight — a white scene with one hot red accent.
- White-haired figure in a flowing white robe seated in a derelict, flower-flooded room — peeling off-white walls, tall bright window, gilt mirror, stone fireplace, and a vintage TV, with white blossoms carpeting the floorboards; misty white-green palette.
- Pale, platinum/albino woman in a long white Victorian lace dress standing on a small grassy islet flanked by two black swans on a misty dawn pond, pines behind, water lilies in the foreground; muted foggy greens and whites.
- Sunlit near-profile of a platinum-blonde woman with long hair and translucent, crystalline glass flowers/butterflies in her hair and over a sheer dress, set in a pastel rose garden under a hazy blue sky; luminous washed-out palette.
- Full-length studio shot of a woman in a deep crimson maxi dress and matching red overcoat, standing against a flat electric cobalt-blue backdrop — the most saturated, high-key-color image in the set (bold red-on-blue, no haze).
- High-contrast black-and-white profile portrait of a woman with a sleek dark bob and pearl drop earrings, in a black blazer against a plain pale-grey backdrop; crisp monochrome.
- High-contrast black-and-white studio portrait of a bald, androgynous young face, three-quarter view with dramatic side-lit shadow, dark collar, plain grey backdrop.
- Soft, grainy black-and-white portrait of a face seen through billowing sheer white organza/veil fabric that half-obscures it; dreamy high-key monochrome.
The remaining tiles continue in these same registers — pale white/floral scenes, one bold saturated studio color shot, and several stark black-and-white portraits — all in the same tall portrait crop. Any similarly eclectic surreal-editorial set works; the deliberate mix of airy washed-out color, one punchy red-on-blue, and high-contrast B&W portraiture matters more than the exact subjects.
Behavior notes
- Desktop-only parallax: the mouse tilt branch is gated behind
!isMobile(innerWidth < 1000); on mobile the spiral only auto-rotates + reacts to scroll, and the camera sits farther back (z = 15). - Inertia, not scrub: scroll doesn't directly rotate the spiral — it deposits velocity that decays, so the spin feels weighty and keeps coasting after you stop.
- Camera descent is clamped:
progressmaxes at 1 after ~1.25 viewports of scroll, so the camera settles at the bottom of the helix and the.aboutsection follows. - No reduced-motion guard in the original; the auto-rotation and render loop run continuously. All colors sit in a near-black palette (
#242424/#171717) so the bright bright tiles pop against the dark surround.
Images
This component ships with 12 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/studiodialect-image-gallery/img1.jpg
https://motionprompts.dev/c/studiodialect-image-gallery/img10.jpg
https://motionprompts.dev/c/studiodialect-image-gallery/img11.jpg
https://motionprompts.dev/c/studiodialect-image-gallery/img12.jpg
https://motionprompts.dev/c/studiodialect-image-gallery/img2.jpg
https://motionprompts.dev/c/studiodialect-image-gallery/img3.jpg
… 6 more under https://motionprompts.dev/c/studiodialect-image-gallery/
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--ink,--charcoal,--bone,--stone,--line,--vermilion. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
Adapting this to React
Everything above describes a mount(config) / destroy() pair that already does most of what a useEffect needs: mount builds the Lenis instance, the shared cameraPositionUniform vector every tile's material reads from, the 75-tile spiral group, the camera and renderer, and the animate() loop that drives all three every frame; the function it returns is already a working teardown — cancels the frame, unhooks the mousemove and resize listeners, tears Lenis down, walks the scene disposing every tile's geometry and material, disposes the ten textures, drops the canvas, and force-loses the WebGL context. What's missing is the same thing missing everywhere else in this catalogue: the file decides for itself when to call mount, and neither of its two branches has anything to do with how React schedules effects.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Call mount() without returning its own destroy as the effect's cleanup, and the second mount finds the first Lenis instance and the first animate() loop still alive: two rAF loops racing to render into two canvases stacked inside .hero, two mousemove/resize listeners double-applying the same parallax tilt to two spirals, two Lenis instances fighting over the same wheel event. None of this reproduces in a production build, since only development performs the double mount.
*(1) The entry point* — delete the if (window.MP && window.MP.register) { … } else { … } dispatch at the bottom of the file; both halves belong to the standalone demo page and its own visual editor, not to a React host. The else branch is the document.readyState guard: it exists so a script parsed after DOMContentLoaded has already fired still boots, a race useEffect never hits because it only runs once the DOM has committed. mount already merges whatever config it receives against DEFAULTS field by field, through its own num()/count() helpers, so the effect doesn't need to spread DEFAULTS itself — it can hand mount this component's own props directly and return whatever it returns:
useEffect(() => {
return mount(props);
}, []);
Do not wrap that call, or the effect callback, in async. Nothing here is awaited — each of the ten TextureLoader.load calls fires a request and returns immediately, and the destroyed check inside its callback is what protects a texture that resolves late — so there is nothing to sequence with await, and doing it anyway turns the effect's return value into a promise instead of destroy, which React has nothing to call on unmount.
*(2) Element lookups* — mount looks up its host once, by class, and bails to a no-op cleanup if it isn't there: const heroSection = document.querySelector(".hero"); if (!heroSection) return () => {};. Replace that with a root ref sized like .hero and pass ref.current in its place; a StrictMode remount briefly keeps two live copies of the subtree, and an unscoped class selector can bind to the copy that's on its way out instead of the one that just mounted. Once the lookup is ref-scoped, heroSection.appendChild(renderer.domElement) already lands the canvas in the right subtree — there's no second selector to fix. The mousemove and resize listeners are bound to window on purpose, because the parallax tilt and the mobile breakpoint both need the whole viewport; leave those two where they are.
*(3) Cleanup* — there is no GSAP or ScrollTrigger anywhere in this file, so the context-revert pattern doesn't apply. Four independent resources have to survive the round trip: the rAF loop, Lenis, the two window listeners, and the GPU memory the spiral holds.
frame is a real handle from requestAnimationFrame, so cancelAnimationFrame(frame) in the cleanup is already correct — keep the if (frame) guard, since a remount whose animate() hasn't scheduled its first frame yet would otherwise call cancelAnimationFrame(null). Lenis here is built with { autoRaf: true }, so it runs its own internal frame loop; there is no lenis.raf(time) call anywhere in this file for you to cancel by hand, and lenis.destroy() alone stops that internal loop. Don't conflate it with animate()'s own frame — that loop drives the camera's descent and the spiral's rotation independently of Lenis's internal tick, and needs its own cancellation regardless of what Lenis does.
The GPU-disposal block — walking scene for every mesh's geometry and material, disposing all ten textures, renderer.dispose(), renderer.forceContextLoss() — has nothing React-specific to change; carry it into the returned cleanup exactly as written. Order matters here: the canvas is removed from .hero *before* any disposal call runs, so a throw partway through disposal can't leave the previous mount's canvas stuck behind the next one. Preserve that ordering if you restructure this around R3F's own cleanup.
The destroyed flag guarding each texture's load callback already does the job this template asks of asynchronous continuations that can resolve after unmount: a StrictMode teardown landing between issuing the ten TextureLoader.load calls and their responses coming back is exactly when a late callback would otherwise write minFilter/anisotropy onto a texture a finished cleanup has already told to dispose(). Keep that check as the first line of every texture callback you port.
*(4) Mapping to @react-three/fiber* — three 0.185, @react-three/fiber 9, drei 10.7, React 19.
<Canvas gl={{ antialias: true, alpha: true }} dpr={[1, 2]} camera={{ fov: 75, near: 0.1, far: 1000, position: [0, 0, 12] }}> replaces the WebGLRenderer / Scene / PerspectiveCamera block outright — dpr={[1, 2]} reproduces the existing Math.min(devicePixelRatio, 2) cap, and <Canvas> already tracks its own container, so the setSize call in mount and the camera.aspect / renderer.setSize lines in the resize listener both disappear. What the resize listener still has to do by hand is the mobile pull-back: past the 1000px breakpoint it also moves the camera farther back along Z. That branch has no <Canvas> equivalent — keep a resize listener (or a media-query hook) that writes the target distance into a ref, and apply it to camera.position.z inside the same useFrame that eases the vertical position.
cameraPositionUniform is a single shared object passed by reference into all 75 tiles' uCameraPosition uniform — the whole depth-dimming shader works only because updating that one Vector3 in place, once per frame, is visible to every tile's material in the same write. Recreate that sharing on purpose: hold const sharedUniform = useMemo(() => ({ value: new THREE.Vector3(0, 0, cameraZ) }), []) once at the gallery's top level, and pass the same object into every tile's <shaderMaterial uniforms={{ uMap: { value: texture }, uCameraPosition: sharedUniform }} side={THREE.DoubleSide} />. The one line that must run every frame is sharedUniform.value.copy(camera.position), inside one useFrame — not inside a per-tile hook, and not something useMemo should ever recompute, since memoizing the uniforms object away would stop this copy from reaching materials built on an earlier render.
animate() becomes that same useFrame callback. Its mutable state — scrollY and spinVelocity from the Lenis scroll handler, mouseX/mouseY/smoothX/smoothY from mousemove — belongs in refs, not component state: every one of them changes on a scroll or pointer event and is read back on every animation frame, and routing any of them through setState would re-render the tree at that rate for numbers nothing in JSX reads back. Give the <group> wrapping the 75 tiles its own ref and mutate its rotation directly inside useFrame: advance rotation.y by the constant drift plus the current spin velocity, decay that velocity by the same factor afterward, and — behind the same desktop-only check — ease the smoothed pointer readings toward the latest raw ones before deriving rotation.x and rotation.z from them exactly as written above. The camera's own vertical ease is the same arithmetic, moved into this callback and applied to camera.position.y via the camera object useThree() hands you.
The per-tile curved geometry has no drei equivalent — it's a hand-built two-row strip sheared so consecutive tiles' edges meet into a continuous ramp — and unlike a gallery where one shape serves every tile, here radius, arc width, tile height, center Y and slope are all different for every one of the 75 tiles, because they taper continuously from the start radius to the end radius across the whole descent. Compute each tile's BufferGeometry in a useMemo keyed on that tile's own index (and on the segment count and height ratio, if those become props) — building 75 vertex arrays inline on every render would rebuild the entire spiral's shape for state changes that have nothing to do with it. TextureLoader.load(...) maps to drei's useTexture(url); keep applying the same minFilter/anisotropy settings once each texture resolves, and keep the i % totalImages cycling so the same ten photos repeat around all 75 tiles exactly as they do today.
A static poster is mandatory, not a nicety. Building 75 custom curved geometries and waiting on ten separate texture loads both take real time, and until the last of the ten resolves, tiles render with whatever uMap defaults to — an untextured spiral flickering into shape tile by tile reads worse than the plain page underneath it. Cover .hero with a poster image sized like the finished spiral and swap it out once the renderer has produced its first real frame, not the instant the effect mounts.
Do not reach for drei's Environment. This scene has no lights and no envMap anywhere in it — the entire depth-dimming effect is the fragment shader comparing each tile's world-space normal against the vector toward uCameraPosition, computed purely from geometry and camera position. An Environment preset would light nothing this material samples, while still making the page depend on drei's third-party CDN for an HDRI it never needed.