All components

Ashfall 3D Cylinder Gallery

WebGL animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A Three.js WebGL gallery arranges dozens of curved image planes into a tall rotating cylinder that spins continuously. Lenis smooth-scroll drives the camera vertically through the stack while scroll velocity briefly accelerates the group's Y rotation for an inertial, momentum-based spin.

How it's built

Category3d-webgl
Techthree, lenis
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

three.js webgl scroll gallery cylinder lenis rotation immersive curved-plane

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

3D Circular Image Gallery

Goal

Build a full-viewport WebGL image gallery where ~100 curved image tiles are wrapped around the surface of a tall vertical cylinder that spins slowly and continuously on its Y axis. Smooth-scrolling drives the camera vertically up and down through the stack, and each burst of scroll velocity briefly accelerates the cylinder's spin for an inertial, momentum-based feel. The star effect is the endlessly rotating 3D drum of photos that you travel through as you scroll.

Tech

Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. No framework.

  • three (npm) — the entire scene is Three.js WebGL. No GSAP is used.
  • lenis (npm) — smooth scroll, whose scroll position and velocity drive the camera and spin.

Import them as:

import * as THREE from "three";
import Lenis from "lenis";

Layout / HTML

Minimal DOM — the gallery is a <canvas> that Three.js appends to <body>. Only two fixed overlay blocks of monospace text sit on top:

<div class="nav">
  <div class="nav-col">
    <p>Silhouette</p>
    <p>Microfolio <br />2017 - Ongoing</p>
  </div>
  <div class="nav-col"><p>Info</p></div>
</div>

<div class="footer">
  <p>Experiment CG407</p>
  <p>By Silhouette</p>
</div>

(Text is a neutral fictional studio label — swap freely; it is decorative only.)

The <canvas> is created by the renderer and appended to body from JS — do not hardcode it in HTML.

Styling

  • Body is 500vh tall (html, body { width: 100%; height: 500vh; }) so there is scroll distance to move the camera. The page is light: background-color: var(--paper) (#f5f2ec) under two fixed radial gradients — a bone lift at 38% height and a powder-blue wash rising from the bottom (background-attachment: fixed, so the ground stays put while the gallery flies past).
  • Palette:

``css :root { --paper: #f5f2ec; --paper-high: #fdfbf6; --navy: #1c2a4a; --navy-soft: #48587a; --accent: #f26430; --accent-soft: #ffb38a; --dust: #a9c1d9; --scrim: 245, 242, 236; /* rgb of --paper, for veils */ } ``

  • canvas { position: fixed; top: 0; left: 0; } — pinned full-screen behind everything.
  • img { width:100%; height:100%; object-fit:cover; } (defensive default; images are used as WebGL textures, not <img> tags).
  • Overlay text p: text-transform: uppercase; font-family: "Space Mono", monospace; font-size: var(--meta-size, 11px); line-height: 1.125; color: var(--navy);Inter for body copy and Space Grotesk for the display line.
  • .navposition: fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; z-index:2; mix-blend-mode: difference;
  • .footer — same but bottom:0, justify-content: space-between.
  • .nav-col { flex:1; }; first nav-col is display:flex with its p children each flex:1; second nav-col is text-align:right.
  • mix-blend-mode: difference on nav and footer is important — the white labels invert against whatever image passes behind them.
  • Include Lenis's recommended CSS (.lenis.lenis-smooth { scroll-behavior: auto !important; }, .lenis.lenis-stopped { overflow: clip; }, etc.).

The 3D effect (be exact)

Renderer, scene, camera
  • THREE.Scene.
  • THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000).
  • THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); renderer.setClearColor(0x000000, 0) (transparent so the bone body shows through). Append renderer.domElement to body.
  • One light only: THREE.AmbientLight(0xffffff, 1) added to the scene.
  • Camera initial position: camera.position.z = 12, camera.position.y = 0 (looking down −Z at the origin by default).
Group + invisible reference cylinder
  • Create const galleryGroup = new THREE.Group() and add it to the scene. All tiles and the reference cylinder are children of this group; the whole group is what rotates.
  • Global constants: radius = 6, height = 30, segments = 30.
  • Add an invisible guide cylinder to the group: THREE.CylinderGeometry(radius, radius, height, segments, 1, true) (open-ended) with MeshPhongMaterial({ color: 0xffffff, transparent: true, opacity: 0, side: THREE.DoubleSide }). It renders nothing — it just documents the drum's dimensions; keep it for fidelity.
Curved image plane geometry (custom BufferGeometry)

Write createCurvedPlane(width, height, radius, segments) that returns a THREE.BufferGeometry — a rectangular mesh bent horizontally to hug a cylinder of the given radius:

  • segmentsX = segments * 4, segmentsY = Math.floor(height * 12), theta = width / radius (the angular arc the tile subtends).
  • Build the vertex grid: for y in 0..segmentsY, yPos = (y/segmentsY - 0.5) * height (vertically centered on 0). For x in 0..segmentsX, xAngle = (x/segmentsX - 0.5) * theta, then xPos = Math.sin(xAngle) * radius, zPos = Math.cos(xAngle) * radius. Push (xPos, yPos, zPos) — this curves the tile outward on the +Z side of a radius-radius circle.
  • UVs with a 10% inset crop: u = (x/segmentsX) * 0.8 + 0.1, v = y/segmentsY. (The *0.8 + 0.1 samples only the middle 80% of the texture horizontally, trimming the left/right edges.)
  • Indices: two triangles per quad — a = x + (segmentsX+1)*y, b = x + (segmentsX+1)*(y+1), c = x+1 + (segmentsX+1)*(y+1), d = x+1 + (segmentsX+1)*y; push (a,b,d) and (b,c,d).
  • Set position (Float32, itemSize 3) and uv (Float32, itemSize 2) attributes, setIndex(indices), then computeVertexNormals().
Texture loading
  • const textureLoader = new THREE.TextureLoader().
  • getRandomImageNumber()Math.floor(Math.random() * 50) + 1 (integer 1–50).
  • loadImageTexture(n) returns a Promise that loads /c/ashfall-3d-image-gallery/img${n}.jpg; in the onLoad callback set generateMipmaps = true, minFilter = THREE.LinearMipmapLinearFilter, magFilter = THREE.LinearFilter, anisotropy = renderer.capabilities.getMaxAnisotropy(), then resolve with the texture.
Placing the tiles on the drum

Layout constants: numVerticalSections = 20, blocksPerSection = 5, verticalSpacing = 5.

  • totalBlockHeight = numVerticalSections * verticalSpacing (100); heightBuffer = (height - totalBlockHeight) / 2 (= −35); startY = -height/2 + heightBuffer + verticalSpacing (= −45).
  • sectionAngle = (Math.PI * 2) / blocksPerSection (72°); maxRandomAngle = sectionAngle * 0.3 (±21.6° jitter).

createBlock(baseY, yOffset, sectionIndex, blockIndex) (async):

  • blockGeometry = createCurvedPlane(2.5, 1.75, radius, 10) — each tile is 2.5 wide × 1.75 tall wrapped on the radius-6 drum.
  • Load a random texture; material = MeshPhongMaterial({ map: texture, side: THREE.DoubleSide, toneMapped: false }).
  • block = new THREE.Mesh(blockGeometry, blockMaterial); block.position.y = baseY + yOffset.
  • Wrap it: const blockContainer = new THREE.Group(). baseAngle = sectionAngle * blockIndex; randomAngleOffset = (Math.random()*2 - 1) * maxRandomAngle; blockContainer.rotation.y = baseAngle + randomAngleOffset. Add the block to the container and return the container.

initializeBlocks() (async): loop section 0..numVerticalSections, baseY = startY + section * verticalSpacing; inner loop i 0..blocksPerSection, yOffset = Math.random()*0.2 - 0.1; await createBlock(...), push to a blocks[] array, and add the container to galleryGroup. Call initializeBlocks() once at startup.

Net result: 20 stacked rings × 5 tiles = ~100 curved photo tiles on a radius-6 cylinder, spanning roughly y = −45…+50, each tile evenly spaced 72° apart with random angular and tiny vertical jitter so the rings don't look mechanical. Each tile independently picks one of 50 source images, so duplicates naturally appear.

Scroll → camera, velocity → spin (the animated part)

State: currentScroll = 0, totalScroll = document.documentElement.scrollHeight - window.innerHeight, rotationSpeed = 0, baseRotationSpeed = 0.0025, maxRotationSpeed = 0.05.

Init Lenis with new Lenis({ autoRaf: true }). On its scroll event:

lenis.on("scroll", (e) => {
  currentScroll = window.pageYOffset;
  rotationSpeed = e.velocity * 0.005;   // fresh spin impulse from scroll speed
});

requestAnimationFrame render loop animate():

  • scrollFraction = currentScroll / totalScroll (0→1 top→bottom).
  • targetY = scrollFraction * height - height/2; camera.position.y = -targetY — so at the top the camera sits at y ≈ +15, at the bottom y ≈ −15, i.e. scrolling down flies the camera down through the drum (about 30 units of vertical travel).
  • galleryGroup.rotation.y += baseRotationSpeed + rotationSpeed — the constant 0.0025 gives the always-on slow spin; the scroll-derived rotationSpeed adds a temporary boost.
  • Then rotationSpeed *= 2; each frame — the impulse is re-amplified frame to frame but gets overwritten by the next scroll event, so it flares while you scroll and collapses back to ~0 (the slow base spin) once the scroll velocity settles.
  • renderer.render(scene, camera); call requestAnimationFrame(animate) at the top of the function; kick it off once with animate().
Resize

window.addEventListener("resize", ...): update camera.aspect = innerWidth/innerHeight, camera.updateProjectionMatrix(), renderer.setSize(innerWidth, innerHeight), renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)).

Assets / images

  • 50 source images named img1.jpg … img50.jpg, served from /c/ashfall-3d-image-gallery/. They are randomly mapped onto the ~100 tiles.
  • Visually: still-life photography and quiet landscape, one plate per frame — ceramics on stone, stacked stoneware, incense smoke, rippled dune sand, folded cloth, single objects on plain grounds. Restrained, cool-leaning neutrals so fifty frames flying past never turn into confetti; the page's own orange accent is the only saturated colour on screen.
  • Roughly square to slightly landscape framing; the geometry crops ~10% off each horizontal edge via the UV inset, so keep subjects centered. Any set of centered abstract renders works.

Behavior notes

  • Desktop-first, not mobile-safe (heavy WebGL; scroll is hijacked by Lenis). No reduced-motion handling in the original — the base spin runs forever.
  • The drum spins continuously even with no scroll input (baseRotationSpeed). Scrolling both moves the camera and briefly speeds the spin proportional to velocity.
  • Everything is transparent-cleared over the bone body; the fixed monospace nav/footer use mix-blend-mode: multiply so they stay legible over passing images without punching a hole in them.

Images

This component ships with 50 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/ashfall-3d-image-gallery/img1.jpg
https://motionprompts.dev/c/ashfall-3d-image-gallery/img10.jpg
https://motionprompts.dev/c/ashfall-3d-image-gallery/img11.jpg
https://motionprompts.dev/c/ashfall-3d-image-gallery/img12.jpg
https://motionprompts.dev/c/ashfall-3d-image-gallery/img13.jpg
https://motionprompts.dev/c/ashfall-3d-image-gallery/img14.jpg
… 44 more under https://motionprompts.dev/c/ashfall-3d-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--paper, --paper-high, --navy, --navy-soft, --accent, --accent-soft, --dust, --scrim, plus the scale tokens --meta-size, --space-1--space-4. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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 *, html, body, 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.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a mount(config) / destroy() pair that is already most of the way to a useEffect: mount builds the Lenis instance, the invisible guide cylinder, the ~100 curved-plane tiles it loads asynchronously, the camera and renderer, and the animate() loop that drives both; the function it returns walks all of that back — canceling the frame, destroying Lenis, disposing every mesh's geometry/material/texture, force-losing the WebGL context, and removing the canvas it created. What's missing is the wiring: the file decides for itself when to call mount, through two branches — a window.MP.register hook for this catalogue's own editor, and a document.readyState guard for everyone else — and neither 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, the first animate() frame loop and the first canvas still alive: two rAF loops racing to render into two canvases behind the same overlay text, two Lenis instances fighting over the same wheel event. It will not reproduce in a production build, because only development does 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 editor, not to a React host. The else branch is the document.readyState guard: it exists so the script survives being parsed after DOMContentLoaded has already fired in a plain document, a race useEffect never hits because it only runs after the DOM is committed. mount already falls back to DEFAULTS internally, 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:

useEffect(() => {
  return mount(props);
}, []);

Do not await anything inside that effect body to sequence it after initializeBlocks() finishes — mount fires that off deliberately without waiting on it, and making the effect callback async (or awaiting inside it) turns the callback into something that returns a promise instead of destroy, which React has nothing to call on unmount.

*(2) Element lookups* — this script never queries the DOM by selector; its one DOM interaction is document.body.appendChild(renderer.domElement). That's actually a point in its favor over slugs that re-query a class name: destroy() already removes the exact canvas this mount created, via the renderer.domElement reference closed over in scope, not a re-query that could bind to a sibling. What still needs fixing is scope: appending straight to document.body puts the canvas outside any component's own subtree, so it always lands as the last child of body, stacking on top of whatever else the app mounts later — a modal, a second instance of this same gallery. Give the component a viewport-sized root ref and append into ref.current instead of document.body, so the canvas's position in the DOM belongs to wherever this component is rendered.

*(3) Cleanup* — three independent resources; there is no GSAP or ScrollTrigger anywhere in this file, so the context-revert pattern doesn't apply here.

Lenis is constructed with autoRaf: true, so it drives its own internal frame loop — there is no lenis.raf(time) call in this file to cancel by hand. lenis.destroy() alone stops that internal loop; keep it and lenis.off("scroll", onLenisScroll) exactly as written. Don't conflate that with the separate frame loop animate() schedules: that one belongs to this component, drives the camera's vertical position and the drum's rotation, and needs its own cancelAnimationFrame(frame) in the cleanup regardless of what Lenis does internally.

The destroyed flag already does the job this template asks of asynchronous continuations, correctly: createBlock checks it the moment await loadImageTexture(...) resolves, and disposes the geometry (and the texture, if one arrived) instead of handing a mesh to a galleryGroup that a finished cleanup has already cleared. initializeBlocks checks the same flag again after every await, inside its section/block loops, so a StrictMode unmount landing mid-drum stops the remaining tiles from ever being queued. Keep both checks exactly where they sit — they're guarding ~100 loads that resolve one at a time (initializeBlocks awaits each createBlock in turn, which is why the drum visibly fills in ring by ring rather than all at once), not a single promise.

*(4) Mapping to @react-three/fiber* — three 0.185, @react-three/fiber 9, drei 10.7, React 19.

<Canvas gl={{ alpha: true }} camera={{ fov: 75, near: 0.1, far: 1000, position: [0, 0, 12] }}> replaces the WebGLRenderer / Scene / PerspectiveCamera block outright; leave the scene background unset so the page's dark background shows through the transparent canvas, the effect setClearColor(0x000000, 0) gets by hand today. renderer.capabilities.getMaxAnisotropy() becomes one useThree(({ gl }) => gl.capabilities.getMaxAnisotropy()) call, read once and cached in a ref — not re-read as each of the ~100 textures resolves, for the same reason the vanilla version reads it once, live, right after the renderer is built.

animate() becomes a single useFrame callback: no requestAnimationFrame loop of your own belongs inside a <Canvas>, since it already runs one, and a second would double the render work against the same GPU context. The values animate() mutates every frame — currentScroll, rotationSpeed, and camera.position.y — belong in refs, not component state: they change on every Lenis scroll event and on every frame respectively, and routing either through setState would re-render the tree at frame rate for numbers nothing in JSX ever reads back. Move the camera-position ease and the rotation-speed decay into the useFrame callback as the same per-frame arithmetic already written above, mutating the group's rotation through a ref to the <group> instead of the module-level THREE.Group.

createCurvedPlane stays a plain function — there's no drei equivalent for a hand-built curved BufferGeometry — but it currently runs once per tile with identical arguments for all ~100 tiles on one drum (only radius varies between drums, never between tiles within one). Compute it once with useMemo keyed on radius and hand the same BufferGeometry to every tile's <mesh geometry={...}>, disposing that one shared instance in the effect's cleanup instead of walking a hundred meshes for it. textureLoader.load(...) maps to drei's useTexture(path) per tile — keep applying the same anisotropy and mipmap-filter settings to whatever it resolves with — and keep each tile's random image pick (getRandomImageNumber()) in a useMemo or state initializer scoped to that tile, not inline in its render body, or a re-render would swap the photo out from under an already-visible tile. Do not wrap all ~100 tiles in one <Suspense> boundary the way a fully-parallel load would justify (see this catalogue's asset-orb-js for that pattern): this drum's loads are sequential by design, and a single Suspense would instead fire all hundred useTexture calls in parallel and pop the whole drum in at once — a visibly different reveal. Preserve the trickle by mounting tile components progressively, in the same section/block order initializeBlocks already uses, instead of relying on Suspense's all-or-nothing commit.

Resize handling disappears with <Canvas>, which already tracks its own container: drop the manual resize listener, updateProjectionMatrix, and the setSize/setPixelRatio calls.

A static poster is mandatory here, more than usually so. Because the drum's tiles load one at a time rather than as one parallel batch, a cold visit can leave it visibly sparse for longer than a component that fires every load at once. Cover the full viewport with a poster matching the dark background and swap it out once enough of the drum has populated to read as a wall of images — not the instant the effect equivalent of mount returns.

Do not reach for drei's Environment, preset or otherwise: the only light in this scene is a single AmbientLight, which has no direction for an environment map to add anything to, and a preset fetches its HDRI from a third-party CDN hard-coded into drei that leaves the scene unlit the moment that host is unreachable. If a future variant wants directional shading on the tiles, add an explicit light instead.