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 "PP Neue Montreal" via @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal") with sans-serif fallback; any Neue-Montreal-like grotesk is fine.
* { margin:0; padding:0; box-sizing:border-box }body { font-family:"PP Neue Montreal", 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.