Cylinder Gallery Drum
A flat PlaneGeometry per item, all sharing one compiled program, each bent around a common cylinder by its own uniform. The camera never moves and the CPU never touches a vertex: scrolling changes one float, `position`, and every plane recomputes its angle, its height and its focus weight from that single scalar. Because the ring offset is the shortest signed distance on a circular index, the list is genuinely endless in both directions with no clones and no rebuilding.
Cylinder Gallery Drum
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/cylinder-gallery.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Cylinder Gallery Drum
A flat PlaneGeometry per item, all sharing one compiled program, each bent around a common cylinder by its own uniform. The camera never moves and the CPU never touches a vertex: scrolling changes one float, `position`, and every plane recomputes its angle, its height and its focus weight from that single scalar. Because the ring offset is the shortest signed distance on a circular index, the list is genuinely endless in both directions with no clones and no rebuilding.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
Nineteen planes wrapped around a drum in the vertex shader, spun by wheel or scroll, focus falling off from centre.
- Category: webgl
- Runtime cost: webgl
- Dependencies: three@^0.185.1, gsap@^3.15.0, lenis@^1.3.11
## How it works
1. The vertex shader treats a vertex's own x as an arc offset: `angle = uSpiralAngle + position.x`, then `pos.x = uRadius * sin(angle)` and `pos.z = uRadius * cos(angle)`, so one world unit of plane width wraps exactly one radian of drum. `pos.y = sin(position.y)` pulls the top and bottom edges in, which turns a rolled sheet into a barrel.
2. Per frame and per plane, `wrappedOffset(i, position, count)` returns the shortest signed distance from that plane to the focused index — the value that makes index 0 sit one step before index 18 rather than eighteen steps after it. That offset drives `uSpiralAngle = (offset / count) * 2π * turns`, the vertical stack `y = -offset * spacing`, and the focus term.
3. Focus is `gaussian(offset)` = `exp(-offset²)`: 1.0 at the centre, 0.368 one step out, 0.018 two, 0.0001 three. The fragment shader mixes toward luminance and dims by that weight, so attention falls off continuously instead of one plane being flagged "selected".
4. A single `uIntro` scalar lerps between two complete layouts inside the shader — `mix(rowPos, ringPos, uIntro)` — where `rowPos` is a flat facing row and `ringPos` is the drum. Every intermediate value is a valid frame, and the CPU lerps the mesh x/y positions with the same scalar so the two layouts stay consistent.
5. Signed scroll velocity feeds `uDeform`, which adds `sin(position.y * 3.0 + 100.0) * uDeform * 0.07` to z and `sin(position.x * 3.0) * uDeform * 0.04` to y. It samples `position` — the original flat coordinates — not the already-bent `pos`, so the ripple is locked to the picture and reads as the image flexing rather than the drum wobbling.
6. Pointer proximity is measured inside the vertex shader by projecting each vertex once as a probe (`ndc = probe.xy / probe.w`), taking `exp(-mouseDist² * 5.0)`, lifting z by half of it and passing it to the fragment shader, which pushes the centred UV outward by the same weight so the bump also magnifies — a lens, not a bump.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `PLANE_WIDTH / PLANE_HEIGHT = 1 / 1.95`
Width 1 because the bend uses the vertex's raw x as radians — at width 1 each plane spans exactly 1 rad of the drum, which is the whole reason no trigonometry is needed on the CPU. Height must stay under 2π/3 or `sin(position.y)` leaves its monotonic range and the barrel folds its own edges back through the surface.
- `CAMERA_FOV / CAMERA_Z = 45 / 5`
The camera never animates, so these only set how much drum is in shot. Below ~35° the ring flattens into a row and the depth cue vanishes; above ~60° the near plane balloons and the barrel curve reads as a keystone error.
- `OVERSCAN = 1.12`
The cover-fit UV is divided by this, zooming the crop in 12%. At exactly 1.0 the wave and the bulge push vertices past the sampled region and the texture's own clamped border smears along the edge. Beyond ~1.25 the crop eats the subject.
- `segments = 64`
The bend is evaluated per vertex, so subdivision *is* the curve. Below ~24 the drum visibly becomes a folded card and the mouse bulge turns into a triangular tent; past ~128 you are paying vertex cost for a curve the pixel grid cannot resolve.
- `POSITION_SMOOTHING = 0.0006`
Fraction of remaining distance left after one second. Larger (slower) and the drum lags visibly behind the wheel; much smaller and the velocity spike that drives `uDeform` never has time to build, so the wave disappears.
- `SNAP_DELAY_MS = 170`
Idle time before the drum eases to a whole index. Under ~120 ms it fires between the discrete events of a single trackpad flick and fights the gesture; over ~250 ms the gallery feels like it forgot to settle.
- `MAX_DEFORM / DEFORM_PER_INDEX = 1.5 / 0.42`
Deform is clamped to ±1.5 because the shader multiplies it by 0.07 world units — past that the planes intersect each other and the far side of the drum pokes through the near side. 0.42 sets how many index-steps per second count as full deflection.
- `RADIUS_VELOCITY_GAIN = 0.5`
The drum widens up to 50% at full speed, which is what sells inertia. Above ~0.8 the nearest plane crosses the camera's near plane during a fast flick and clips away.
- `gaussian falloff = 1 (exp(-offset²))`
A falloff of 1 keeps the immediate neighbours legible at 0.37 while the third plane out is effectively gone. Raising it dims the neighbours to black and the ring loses its context; lowering it flattens every plane to the same weight.
- `renderOrder scale = -worldZ * 1000`
Reconstructed drum depth, scaled up so it survives rounding to an integer sort key. At a smaller scale adjacent planes collapse into the same bucket and flicker between frames.
## API
```ts
function CylinderGallery(props: { items: CylinderGalleryItem[]; drive?: "pointer" | "page"; radius?: number; turns?: number; spacing?: number; segments?: number; pixelsPerIndex?: number; opacity?: number; paint?: (ctx: CanvasRenderingContext2D, item: CylinderGalleryItem, index: number, size: { width: number; height: number }) => void; onSelect?: (item: CylinderGalleryItem, index: number) => void; onFocusChange?: (item: CylinderGalleryItem, index: number) => void; className?: string }): JSX.Element
```
- `items: CylinderGalleryItem[]`
`{ id, title, meta?, hue? }` per plane. One mesh and one generated texture each.
- `drive: "pointer" | "page"` — default `"pointer"`
Wheel and drag inside the component, or document scroll via a shared infinite Lenis.
- `radius: number` — default `2`
Drum radius in world units, before the velocity widening.
- `turns: number` — default `3`
Full revolutions the ring spans across the whole list.
- `spacing: number` — default `0.8`
World units of vertical travel per index step.
- `segments: number` — default `64`
Plane subdivisions per axis. This is the resolution of the bend.
- `pixelsPerIndex: number` — default `240`
Document scroll pixels per index step, in `page` mode only.
- `opacity: number` — default `1`
Global alpha multiplier applied before the focus falloff.
- `paint: (ctx, item, index, size) => void`
Override the generated plate. Receives a 2D context for a 512x1024 canvas.
- `onSelect: (item, index) => void`
Tap on the drum. A drag never counts as a tap.
- `onFocusChange: (item, index) => void`
Fires when a different index takes the centre.
- `className: string`
Merged onto the wrapper via `cn`.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
export function CylinderGallery({
items,
drive = 'pointer',
radius = 2,
turns = 3,
spacing = 0.8,
segments = 64,
pixelsPerIndex = 240,
opacity = 1,
paint,
onSelect,
onFocusChange,
className,
}: CylinderGalleryProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const { width, height } = useResizeObserver(host);
const { inView } = useInView(host, { threshold: 0 });
const visible = useVisibility();
const reduced = useReducedMotion();
const pointer = useSmoothPointer({ target: host, smoothing: 0.0015 });
// `hasWebGL` touches `document`, so it cannot run during render without
// disagreeing with the server. Start optimistic and correct after mount:
// both renders then agree and nothing hydrates twice.
const [supported, setSupported] = useState(true);
useEffect(() => setSupported(hasWebGL()), []);
const [focused, setFocused] = useState(0);
const position = useRef(0);
const target = useRef(0);
const velocity = useRef(0);
const intro = useRef(0);
const strength = useRef(0);
const hovered = useRef(false);
const focusedRef = useRef(0);
const snapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const snapTween = useRef<Tween | null>(null);
// Everything the frame loop reads but must not resubscribe on. Written on
// every render, read once per frame; the loop itself never re-creates.
const live = useRef({ radius, turns, spacing, opacity, drive, pixelsPerIndex, items, onSelect, onFocusChange });
live.current = { radius, turns, spacing, opacity, drive, pixelsPerIndex, items, onSelect, onFocusChange };
// Rebuilding the scene is expensive, so it is keyed on what actually changes
// the meshes — identity, label and hue — not on the array's reference, which
// is new on every render for any caller who writes the list inline.
const itemsKey = items.map((item) => `${item.id}:${item.title}:${item.hue ?? ''}`).join('|');
const lenis = useLenis({ infinite: true, enabled: drive === 'page' });
const killSnap = useCallback(() => {
if (snapTimer.current !== null) {
clearTimeout(snapTimer.current);
snapTimer.current = null;
}
snapTween.current?.kill();
snapTween.current = null;
}, []);
const scheduleSnap = useCallback(() => {
if (snapTimer.current !== null) clearTimeout(snapTimer.current);
snapTimer.current = setTimeout(() => {
snapTimer.current = null;
// Snap the TARGET, not the position: the frame loop keeps damping
// toward it, so the settle stays one continuous motion instead of a
// second animation fighting the first.
snapTween.current = gsap.to(target, {
current: Math.round(target.current),
duration: SNAP_DURATION,
ease: 'power3.out',
});
}, SNAP_DELAY_MS);
}, []);
/* ---- scene construction ------------------------------------------------ */
const ready = width > 1 && height > 1 && supported;
useEffect(() => {
const el = host.current;
if (!el || !ready || items.length === 0) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Capped, not raw. A 3x phone with an uncapped ratio pays 9x the fill rate
// for a difference nobody can see.
renderer.setPixelRatio(pixelRatio());
renderer.setSize(el.clientWidth, el.clientHeight, false);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
CAMERA_FOV,
el.clientWidth / Math.max(el.clientHeight, 1),
0.1,
100,
);
camera.position.z = CAMERA_Z;
// One geometry for every plane. The bend is per-vertex, so the mesh has to
// be dense enough that the curve reads as a curve: below ~24 segments the
// drum visibly becomes a folded card.
const geometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT, segments, segments);
const planeAspect = PLANE_WIDTH / PLANE_HEIGHT;
const paintItem = paint ?? paintPlate;
const maxAnisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
const planes: Plane[] = items.map((item, index) => {
const canvas = document.createElement('canvas');
canvas.width = TEXTURE_WIDTH;
canvas.height = TEXTURE_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx) paintItem(ctx, item, index, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT });
const texture = new THREE.CanvasTexture(canvas);
// sRGB in, sRGB out. Skip this and the plates render noticeably darker
// than the CSS colours sitting next to them.
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxAnisotropy;
texture.needsUpdate = true;
const uniforms: PlaneUniforms = {
uTexture: { value: texture },
uTextureAspect: { value: TEXTURE_WIDTH / TEXTURE_HEIGHT },
uPlaneAspect: { value: planeAspect },
uOverscan: { value: OVERSCAN },
uSpiralAngle: { value: 0 },
uRadius: { value: radius },
uIntro: { value: 0 },
uFocus: { value: 1 },
uOpacity: { value: opacity },
uDeform: { value: 0 },
uMouseNdc: { value: new THREE.Vector2(0, 0) },
uMouseStrength: { value: 0 },
};
// A material per mesh, because every plane needs its own spiralAngle,
// radius and focus. three keys its program cache on the shader source,
// so all of these share one compiled program regardless.
const material = new THREE.ShaderMaterial({
vertexShader: cylinderGalleryVertexShader,
fragmentShader: cylinderGalleryFragmentShader,
uniforms,
transparent: true,
side: THREE.DoubleSide,
// Painted strictly back to front via renderOrder below, so neither
// depth write nor depth test has anything to contribute — and both
// would be wrong anyway, since the depth they compare is the depth of
// a mesh the CPU thinks is still flat at the origin.
depthWrite: false,
depthTest: false,
});
const mesh = new THREE.Mesh(geometry, material);
// The CPU-side geometry is a flat plane at mesh.position; the shape you
// can actually see is somewhere else entirely. Frustum culling compares
// against that stale bounding sphere and blanks planes that are plainly
// on screen.
mesh.frustumCulled = false;
scene.add(mesh);
return { mesh, uniforms, texture };
});
stage.current = { renderer, scene, camera, geometry, planes };
renderer.render(scene, camera);
return () => {
stage.current = null;
for (const plane of planes) {
scene.remove(plane.mesh);
// disposeObject walks a material's own properties looking for
// textures. These live inside `uniforms`, two levels down, so it will
// never find them — they have to be released by hand.
plane.texture.dispose();
}
disposeObject(scene);
geometry.dispose();
// dispose() frees GPU objects and leaves the context alive. Only
// forceContextLoss, which disposeRenderer calls, hands it back.
disposeRenderer(renderer);
renderer.domElement.remove();
};
// `items` is read through the key; `radius`/`opacity` only seed uniforms
// the frame loop overwrites immediately.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, itemsKey, segments, paint]);
/* ---- size ------------------------------------------------------------- */
useEffect(() => {
const current = stage.current;
if (!current || width < 1 || height < 1) return;
current.renderer.setPixelRatio(pixelRatio());
// `false` — the canvas is sized by CSS at 100%/100%; letting three write
// inline pixel dimensions makes it fight the flex parent on every resize.
current.renderer.setSize(width, height, false);
current.camera.aspect = width / height;
current.camera.updateProjectionMatrix();
}, [width, height]);
/* ---- intro morph ------------------------------------------------------- */
useEffect(() => {
if (!ready) return;
if (reduced) {
intro.current = 1;
return;
}
intro.current = 0;
const tween = gsap.to(intro, {
current: 1,
duration: INTRO_DURATION,
delay: INTRO_DELAY,
ease: 'power3.inOut',
});
return () => {
tween.kill();
};
}, [ready, reduced]);
/* ---- input ------------------------------------------------------------- */
useEffect(() => {
const el = host.current;
if (!el || drive !== 'pointer') return;
const onWheel = (event: WheelEvent) => {
// Non-passive on purpose: without preventDefault the drum spins AND the
// page scrolls under it, which reads as two competing gestures.
event.preventDefault();
killSnap();
target.current += event.deltaY * WHEEL_INDEX_PER_PIXEL;
scheduleSnap();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [drive, killSnap, scheduleSnap]);
useEffect(() => {
const el = host.current;
if (!el) return;
const enter = () => {
hovered.current = true;
};
const leave = () => {
hovered.current = false;
};
el.addEventListener('pointerenter', enter);
el.addEventListener('pointerleave', leave);
return () => {
el.removeEventListener('pointerenter', enter);
el.removeEventListener('pointerleave', leave);
};
}, []);
useDrag(host, {
enabled: drive === 'pointer',
onDragStart: killSnap,
onDrag: (state) => {
target.current -= state.deltaY * DRAG_INDEX_PER_PIXEL;
},
onDragEnd: (_state, moved) => {
if (!moved) {
const { items: list, onSelect: select } = live.current;
const index = wrapIndex(Math.round(position.current), list.length);
const item = list[index];
if (item) select?.(item, index);
}
scheduleSnap();
},
});
useEffect(() => killSnap, [killSnap]);
/* ---- the frame --------------------------------------------------------- */
useRaf(
(dt) => {
const current = stage.current;
if (!current) return;
const {
radius: baseRadius,
turns: baseTurns,
spacing: baseSpacing,
opacity: alpha,
drive: mode,
pixelsPerIndex: perIndex,
items: list,
onFocusChange: notify,
} = live.current;
const count = current.planes.length;
if (count === 0) return;
if (mode === 'page') {
const instance = lenis.current;
if (instance) target.current = instance.scroll / Math.max(perIndex, 1);
}
const previous = position.current;
position.current = damp(position.current, target.current, POSITION_SMOOTHING, dt);
// Index steps per second, smoothed by the same damp that produced the
// position, so the wave can never lead the geometry it is bending.
const instant = dt > 0 ? (position.current - previous) / dt : 0;
velocity.current = damp(velocity.current, instant, 0.002, dt);
const normalized = clamp(velocity.current / VELOCITY_REFERENCE, -1, 1);
strength.current = damp(
strength.current,
hovered.current && !reduced ? 1 : 0,
STRENGTH_SMOOTHING,
dt,
);
const s = intro.current;
const speed = Math.abs(normalized);
const liveRadius = baseRadius * (1 + RADIUS_VELOCITY_GAIN * speed);
const liveSpacing = baseSpacing * (1 + SPACING_VELOCITY_GAIN * speed);
const angularScale = 1 + ANGULAR_VELOCITY_GAIN * speed;
const deform = reduced
? 0
: clamp(velocity.current * DEFORM_PER_INDEX, -MAX_DEFORM, MAX_DEFORM);
// The flat-row layout spans the frustum's visible WIDTH at z = 0. Sizing
// it from window.innerWidth, or from the visible height, leaves the row
// short on wide viewports and clipped on narrow ones.
const visibleWidth =
2 * Math.tan((CAMERA_FOV * Math.PI) / 360) * CAMERA_Z * current.camera.aspect * 0.86;
const step = visibleWidth / Math.max(count - 1, 1);
const rowWidth = (count - 1) * step;
const mx = pointer.current.active ? pointer.current.ndcX : 0;
const my = pointer.current.active ? pointer.current.ndcY : 0;
for (let i = 0; i < count; i++) {
const plane = current.planes[i];
if (!plane) continue;
// The one call that makes a finite array behave like an infinite ring:
// item 0 is one step BEFORE item 18, not eighteen steps after it.
const offset = wrappedOffset(i, position.current, count);
const angle = (offset / count) * Math.PI * 2 * baseTurns * angularScale;
const y = -offset * liveSpacing;
plane.mesh.position.x = (i * step - rowWidth / 2) * (1 - s);
plane.mesh.position.y = y * s;
plane.mesh.scale.setScalar(lerp(0.62, 1, s));
const u = plane.uniforms;
u.uSpiralAngle.value = angle;
u.uRadius.value = liveRadius;
u.uIntro.value = s;
u.uDeform.value = deform;
u.uOpacity.value = alpha;
u.uMouseStrength.value = strength.current;
u.uMouseNdc.value.set(mx, my);
// exp(-d²): 1.000 at the centre, 0.368 one step out, 0.018 two, 0.0001
// three. Continuous, branchless, and the reason the drum reads as
// analogue rather than as a list with a selected row.
u.uFocus.value = lerp(1, gaussian(offset), s);
// Transparent meshes are sorted by three using the CPU position, which
// for this drum is (0, y, 0) for every plane. Reconstructing the depth
// the shader will actually produce and painting back to front is the
// only way the far side of the ring stays behind the near side.
const worldZ = liveRadius * Math.cos(angle) * s;
plane.mesh.renderOrder = Math.round(-worldZ * 1000);
}
const nextFocus = wrapIndex(Math.round(position.current), count);
if (nextFocus !== focusedRef.current) {
focusedRef.current = nextFocus;
setFocused(nextFocus);
const item = list[nextFocus];
if (item) notify?.(item, nextFocus);
}
current.renderer.render(current.scene, current.camera);
},
ready && inView && visible,
);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Removes the wheel and pointerenter/pointerleave listeners, kills the intro tween and any pending snap timeout plus its tween, removes every mesh from the scene, disposes each `CanvasTexture` by hand (they sit inside `material.uniforms`, where `disposeObject` cannot reach), runs `disposeObject` for the geometries and materials, disposes the shared geometry, then `disposeRenderer` — `setAnimationLoop(null)`, `dispose()`, `forceContextLoss()` — and removes the canvas from the DOM. The frame loop reads a stage ref that is nulled first, so a queued frame after teardown is a no-op rather than a null context throw.
**Reduced motion.** The intro morph is skipped — `uIntro` is set to 1 on mount, so the drum is simply already assembled. The velocity wave is pinned to 0 and the pointer bulge never engages, so the surface stays flat. Navigation still works and still eases: wheel, drag and the snap-to-index tween are unchanged, because the user asked for less motion, not for a gallery that cannot move.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Scaling `uRadius` from 0 to animate the intro. At radius 0 every vertex of every plane lands on the origin, so the "flat" state is nineteen invisible slivers stacked in one spot. Lerp between two position vectors — `mix(rowPos, ringPos, uIntro)` — and the flat state is a real layout you can hold.
- Leaving `frustumCulled` at its default. The CPU-side geometry is still a flat plane sitting at `mesh.position`; the visible shape is somewhere else entirely. three culls against that stale bounding sphere and planes that are plainly on screen blink out at the edges.
- Trusting three's transparency sort. It orders transparent meshes by their object position, which for this drum is `(0, y, 0)` for every single plane, so the far side of the ring paints over the near side at random. Reconstruct the depth the shader will produce (`radius * cos(angle) * intro`) and set `renderOrder` from it each frame.
- Expecting `disposeObject` to free the plate textures. It scans a material's own properties for anything with `isTexture`; these textures live two levels down inside `material.uniforms`, so it never sees them and nineteen 512x1024 canvases stay resident on the GPU after unmount. They are disposed explicitly from a tracked list.
- Calling `renderer.dispose()` and stopping there. That releases GPU objects but keeps the WebGL context alive; Chromium caps a renderer process at roughly 16 and silently kills the oldest, so a docs page that mounts several of these blanks the ones above it. `disposeRenderer` calls `forceContextLoss`.
- Attaching the wheel listener passively. Without `preventDefault` the drum spins and the host page scrolls at the same time, which reads as two gestures fighting; the listener is registered with `{ passive: false }`.
- Sampling the deformation wave from the already-bent `pos` instead of the original `position`. The ripple then travels with the drum rotation rather than staying pinned to the image, and the effect looks like a rendering fault instead of the picture flexing.
- Sizing the flat-row layout from `window.innerWidth`. The row has to span the frustum's visible width at z = 0, which is `2 * tan(fov/2) * cameraZ * aspect` — the container width in pixels has no fixed relationship to that, so the row overshoots on wide viewports and clips on narrow ones.
- Omitting `#include <colorspace_fragment>` from a `ShaderMaterial`. three only injects output conversion into its own materials, so raw linear values ship into an sRGB framebuffer and the plates render visibly washed out next to the CSS around them.
- Rebuilding the scene when the `items` array identity changes. Any caller who writes the list inline hands over a new array every render, which would tear down nineteen textures and a GL context on every keystroke. The setup effect keys on a string built from id, title and hue.
## Notes
- Textures are drawn at mount to a 512x1024 canvas per item — a hue ramp, hairline bands and a numeral. The bands are not decoration: a smooth gradient hides every deformation the shader applies, so the plate needs structure for the wave and the bulge to bend.
- All nineteen materials are separate `ShaderMaterial` instances because each needs its own uniforms, but three keys its program cache on the shader source, so they share a single compiled program.
- `drive: "page"` opts into a shared refcounted Lenis with `infinite: true`, which is the technique in its original form — the gallery *is* the page. The default `drive: "pointer"` keeps every gesture inside the component's own box.
## Definition of done
On mount the planes start as a flat row facing you and curl into a drum over about 1.6 s. Wheel or drag spins it endlessly in both directions with no seam and no jump at the ends; stop moving and it eases to centre one plane. The centred plane is full colour and the neighbours fade toward grey within two steps. Flick hard and the images ripple, the drum widens, and the ripple runs opposite ways depending on direction. Move the pointer over a plane and a soft lens follows it, magnifying rather than just denting. Unmount from React DevTools and the canvas leaves the DOM with no context-lost warning; mount and unmount twenty times and no context is dropped. With `prefers-reduced-motion: reduce` the drum is already assembled on the first frame and neither the ripple nor the lens ever appears.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
13 files, exactly as installed.
'use client';
import gsap from 'gsap';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { cn } from '@/lib/utils';
import { useDrag } from '@/hooks/use-drag';
import { useInView } from '@/hooks/use-in-view';
import { useLenis } from '@/hooks/use-lenis';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { clamp, damp, gaussian, lerp } from '@/lib/damp';
import {
disposeObject,
disposeRenderer,
hasWebGL,
pixelRatio,
} from '@/lib/webgl';
import { wrapIndex, wrappedOffset } from '@/lib/wrap';
import {
cylinderGalleryFragmentShader,
cylinderGalleryVertexShader,
} from '@/components/motion/shaders/cylinder-gallery';
/* ==========================================================================
Cylinder Gallery Drum
--------------------------------------------------------------------------
A portfolio as a rotating drum: one plane per item, wrapped around the
inside of a cylinder you are standing in the middle of. The camera never
moves. Every bit of motion is vertices being repositioned in the vertex
shader, which is why nineteen planes cost nineteen draw calls and nothing
else.
The whole component is four scalars — position, radius, spacing, intro —
plus one shortest-signed-offset call per plane per frame.
========================================================================== */
export interface CylinderGalleryItem {
/** Stable identity. Also the React key and the texture cache key. */
id: string;
/** Shown in the caption and burned into the generated plate. */
title: string;
/** Small caption under the title. */
meta?: string;
/** Base hue (0-360) for the generated plate. Defaults to an even spread. */
hue?: number;
}
export interface CylinderGalleryProps {
items: CylinderGalleryItem[];
/**
* `pointer` — the drum consumes wheel and drag inside its own box. Nothing
* touches the host page's scroll. This is the right default for a gallery
* embedded in a page.
*
* `page` — a shared Lenis instance with `infinite: true` drives the drum
* from document scroll, which is what the technique looks like when the
* gallery IS the page.
*/
drive?: 'pointer' | 'page';
/** Drum radius in world units. Also scales with scroll velocity. */
radius?: number;
/** Full revolutions the ring spans across all items. */
turns?: number;
/** Vertical world units between adjacent items. */
spacing?: number;
/** Plane subdivisions per axis. The bend is per-vertex, so this is the curve. */
segments?: number;
/** Pixels of document scroll per index step, in `page` mode. */
pixelsPerIndex?: number;
/** Overall alpha multiplier, before the focus falloff. */
opacity?: number;
/** Paint the plate for an item. Default draws a hue ramp plus a numeral. */
paint?: (
ctx: CanvasRenderingContext2D,
item: CylinderGalleryItem,
index: number,
size: { width: number; height: number },
) => void;
/** Fired when the focused plane is tapped (a drag never counts as a tap). */
onSelect?: (item: CylinderGalleryItem, index: number) => void;
/** Fired whenever a different item takes the centre of the drum. */
onFocusChange?: (item: CylinderGalleryItem, index: number) => void;
className?: string;
}
/* --------------------------------------------------------------------------
Geometry and camera. These four are a matched set: the plane is 1 unit wide
so its width maps to exactly one radian of the drum, and the camera sits far
enough back that a 1.95-tall plane still clears the frustum after the barrel
pulls its edges in.
-------------------------------------------------------------------------- */
const PLANE_WIDTH = 1;
const PLANE_HEIGHT = 1.95;
const CAMERA_FOV = 45;
const CAMERA_Z = 5;
const TEXTURE_WIDTH = 512;
const TEXTURE_HEIGHT = 1024;
const OVERSCAN = 1.12;
const WHEEL_INDEX_PER_PIXEL = 0.0028;
const DRAG_INDEX_PER_PIXEL = 0.006;
const SNAP_DELAY_MS = 170;
const SNAP_DURATION = 0.75;
const INTRO_DURATION = 1.6;
const INTRO_DELAY = 0.12;
const POSITION_SMOOTHING = 0.0006;
const STRENGTH_SMOOTHING = 0.002;
const VELOCITY_REFERENCE = 5;
const MAX_DEFORM = 1.5;
const DEFORM_PER_INDEX = 0.42;
const RADIUS_VELOCITY_GAIN = 0.5;
const SPACING_VELOCITY_GAIN = 0.35;
const ANGULAR_VELOCITY_GAIN = 0.2;
/** Uniform bag, as a type alias so it keeps its implicit index signature and
* is assignable to `ShaderMaterial`'s `{ [name: string]: IUniform }`. */
type PlaneUniforms = {
uTexture: { value: THREE.Texture | null };
uTextureAspect: { value: number };
uPlaneAspect: { value: number };
uOverscan: { value: number };
uSpiralAngle: { value: number };
uRadius: { value: number };
uIntro: { value: number };
uFocus: { value: number };
uOpacity: { value: number };
uDeform: { value: number };
uMouseNdc: { value: THREE.Vector2 };
uMouseStrength: { value: number };
};
interface Plane {
mesh: THREE.Mesh;
uniforms: PlaneUniforms;
texture: THREE.CanvasTexture;
}
interface Stage {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
geometry: THREE.PlaneGeometry;
planes: Plane[];
}
/* --------------------------------------------------------------------------
Plates are drawn, never loaded. No image ships with this component and none
is fetched: a canvas per item, a hue ramp, hairline bands and a numeral.
The bands matter — a smooth gradient hides every deformation the shader
applies, so there has to be structure for the wave and the bulge to bend.
-------------------------------------------------------------------------- */
function paintPlate(
ctx: CanvasRenderingContext2D,
item: CylinderGalleryItem,
index: number,
size: { width: number; height: number },
): void {
const { width, height } = size;
const hue = item.hue ?? (index * 37) % 360;
const ramp = ctx.createLinearGradient(0, 0, width * 0.65, height);
ramp.addColorStop(0, `hsl(${hue}, 55%, 12%)`);
ramp.addColorStop(0.55, `hsl(${hue + 16}, 52%, 33%)`);
ramp.addColorStop(1, `hsl(${hue + 44}, 68%, 63%)`);
ctx.fillStyle = ramp;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.13)';
ctx.lineWidth = Math.max(1, width / 320);
for (let band = 1; band < 10; band++) {
const y = (height * band) / 10;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = Math.max(2, width / 96);
ctx.strokeRect(ctx.lineWidth / 2, ctx.lineWidth / 2, width - ctx.lineWidth, height - ctx.lineWidth);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
ctx.font = `600 ${Math.round(height * 0.3)}px ui-sans-serif, system-ui, sans-serif`;
ctx.fillText(String(index + 1).padStart(2, '0'), width / 2, height * 0.44);
ctx.fillStyle = 'rgba(255, 255, 255, 0.82)';
ctx.font = `500 ${Math.round(height * 0.038)}px ui-monospace, SFMono-Regular, monospace`;
ctx.fillText(item.title.toUpperCase(), width / 2, height * 0.88);
}
type Tween = ReturnType<typeof gsap.to>;
// #region critical
export function CylinderGallery({
items,
drive = 'pointer',
radius = 2,
turns = 3,
spacing = 0.8,
segments = 64,
pixelsPerIndex = 240,
opacity = 1,
paint,
onSelect,
onFocusChange,
className,
}: CylinderGalleryProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const { width, height } = useResizeObserver(host);
const { inView } = useInView(host, { threshold: 0 });
const visible = useVisibility();
const reduced = useReducedMotion();
const pointer = useSmoothPointer({ target: host, smoothing: 0.0015 });
// `hasWebGL` touches `document`, so it cannot run during render without
// disagreeing with the server. Start optimistic and correct after mount:
// both renders then agree and nothing hydrates twice.
const [supported, setSupported] = useState(true);
useEffect(() => setSupported(hasWebGL()), []);
const [focused, setFocused] = useState(0);
const position = useRef(0);
const target = useRef(0);
const velocity = useRef(0);
const intro = useRef(0);
const strength = useRef(0);
const hovered = useRef(false);
const focusedRef = useRef(0);
const snapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const snapTween = useRef<Tween | null>(null);
// Everything the frame loop reads but must not resubscribe on. Written on
// every render, read once per frame; the loop itself never re-creates.
const live = useRef({ radius, turns, spacing, opacity, drive, pixelsPerIndex, items, onSelect, onFocusChange });
live.current = { radius, turns, spacing, opacity, drive, pixelsPerIndex, items, onSelect, onFocusChange };
// Rebuilding the scene is expensive, so it is keyed on what actually changes
// the meshes — identity, label and hue — not on the array's reference, which
// is new on every render for any caller who writes the list inline.
const itemsKey = items.map((item) => `${item.id}:${item.title}:${item.hue ?? ''}`).join('|');
const lenis = useLenis({ infinite: true, enabled: drive === 'page' });
const killSnap = useCallback(() => {
if (snapTimer.current !== null) {
clearTimeout(snapTimer.current);
snapTimer.current = null;
}
snapTween.current?.kill();
snapTween.current = null;
}, []);
const scheduleSnap = useCallback(() => {
if (snapTimer.current !== null) clearTimeout(snapTimer.current);
snapTimer.current = setTimeout(() => {
snapTimer.current = null;
// Snap the TARGET, not the position: the frame loop keeps damping
// toward it, so the settle stays one continuous motion instead of a
// second animation fighting the first.
snapTween.current = gsap.to(target, {
current: Math.round(target.current),
duration: SNAP_DURATION,
ease: 'power3.out',
});
}, SNAP_DELAY_MS);
}, []);
/* ---- scene construction ------------------------------------------------ */
const ready = width > 1 && height > 1 && supported;
useEffect(() => {
const el = host.current;
if (!el || !ready || items.length === 0) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Capped, not raw. A 3x phone with an uncapped ratio pays 9x the fill rate
// for a difference nobody can see.
renderer.setPixelRatio(pixelRatio());
renderer.setSize(el.clientWidth, el.clientHeight, false);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
CAMERA_FOV,
el.clientWidth / Math.max(el.clientHeight, 1),
0.1,
100,
);
camera.position.z = CAMERA_Z;
// One geometry for every plane. The bend is per-vertex, so the mesh has to
// be dense enough that the curve reads as a curve: below ~24 segments the
// drum visibly becomes a folded card.
const geometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT, segments, segments);
const planeAspect = PLANE_WIDTH / PLANE_HEIGHT;
const paintItem = paint ?? paintPlate;
const maxAnisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
const planes: Plane[] = items.map((item, index) => {
const canvas = document.createElement('canvas');
canvas.width = TEXTURE_WIDTH;
canvas.height = TEXTURE_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx) paintItem(ctx, item, index, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT });
const texture = new THREE.CanvasTexture(canvas);
// sRGB in, sRGB out. Skip this and the plates render noticeably darker
// than the CSS colours sitting next to them.
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxAnisotropy;
texture.needsUpdate = true;
const uniforms: PlaneUniforms = {
uTexture: { value: texture },
uTextureAspect: { value: TEXTURE_WIDTH / TEXTURE_HEIGHT },
uPlaneAspect: { value: planeAspect },
uOverscan: { value: OVERSCAN },
uSpiralAngle: { value: 0 },
uRadius: { value: radius },
uIntro: { value: 0 },
uFocus: { value: 1 },
uOpacity: { value: opacity },
uDeform: { value: 0 },
uMouseNdc: { value: new THREE.Vector2(0, 0) },
uMouseStrength: { value: 0 },
};
// A material per mesh, because every plane needs its own spiralAngle,
// radius and focus. three keys its program cache on the shader source,
// so all of these share one compiled program regardless.
const material = new THREE.ShaderMaterial({
vertexShader: cylinderGalleryVertexShader,
fragmentShader: cylinderGalleryFragmentShader,
uniforms,
transparent: true,
side: THREE.DoubleSide,
// Painted strictly back to front via renderOrder below, so neither
// depth write nor depth test has anything to contribute — and both
// would be wrong anyway, since the depth they compare is the depth of
// a mesh the CPU thinks is still flat at the origin.
depthWrite: false,
depthTest: false,
});
const mesh = new THREE.Mesh(geometry, material);
// The CPU-side geometry is a flat plane at mesh.position; the shape you
// can actually see is somewhere else entirely. Frustum culling compares
// against that stale bounding sphere and blanks planes that are plainly
// on screen.
mesh.frustumCulled = false;
scene.add(mesh);
return { mesh, uniforms, texture };
});
stage.current = { renderer, scene, camera, geometry, planes };
renderer.render(scene, camera);
return () => {
stage.current = null;
for (const plane of planes) {
scene.remove(plane.mesh);
// disposeObject walks a material's own properties looking for
// textures. These live inside `uniforms`, two levels down, so it will
// never find them — they have to be released by hand.
plane.texture.dispose();
}
disposeObject(scene);
geometry.dispose();
// dispose() frees GPU objects and leaves the context alive. Only
// forceContextLoss, which disposeRenderer calls, hands it back.
disposeRenderer(renderer);
renderer.domElement.remove();
};
// `items` is read through the key; `radius`/`opacity` only seed uniforms
// the frame loop overwrites immediately.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, itemsKey, segments, paint]);
/* ---- size ------------------------------------------------------------- */
useEffect(() => {
const current = stage.current;
if (!current || width < 1 || height < 1) return;
current.renderer.setPixelRatio(pixelRatio());
// `false` — the canvas is sized by CSS at 100%/100%; letting three write
// inline pixel dimensions makes it fight the flex parent on every resize.
current.renderer.setSize(width, height, false);
current.camera.aspect = width / height;
current.camera.updateProjectionMatrix();
}, [width, height]);
/* ---- intro morph ------------------------------------------------------- */
useEffect(() => {
if (!ready) return;
if (reduced) {
intro.current = 1;
return;
}
intro.current = 0;
const tween = gsap.to(intro, {
current: 1,
duration: INTRO_DURATION,
delay: INTRO_DELAY,
ease: 'power3.inOut',
});
return () => {
tween.kill();
};
}, [ready, reduced]);
/* ---- input ------------------------------------------------------------- */
useEffect(() => {
const el = host.current;
if (!el || drive !== 'pointer') return;
const onWheel = (event: WheelEvent) => {
// Non-passive on purpose: without preventDefault the drum spins AND the
// page scrolls under it, which reads as two competing gestures.
event.preventDefault();
killSnap();
target.current += event.deltaY * WHEEL_INDEX_PER_PIXEL;
scheduleSnap();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [drive, killSnap, scheduleSnap]);
useEffect(() => {
const el = host.current;
if (!el) return;
const enter = () => {
hovered.current = true;
};
const leave = () => {
hovered.current = false;
};
el.addEventListener('pointerenter', enter);
el.addEventListener('pointerleave', leave);
return () => {
el.removeEventListener('pointerenter', enter);
el.removeEventListener('pointerleave', leave);
};
}, []);
useDrag(host, {
enabled: drive === 'pointer',
onDragStart: killSnap,
onDrag: (state) => {
target.current -= state.deltaY * DRAG_INDEX_PER_PIXEL;
},
onDragEnd: (_state, moved) => {
if (!moved) {
const { items: list, onSelect: select } = live.current;
const index = wrapIndex(Math.round(position.current), list.length);
const item = list[index];
if (item) select?.(item, index);
}
scheduleSnap();
},
});
useEffect(() => killSnap, [killSnap]);
/* ---- the frame --------------------------------------------------------- */
useRaf(
(dt) => {
const current = stage.current;
if (!current) return;
const {
radius: baseRadius,
turns: baseTurns,
spacing: baseSpacing,
opacity: alpha,
drive: mode,
pixelsPerIndex: perIndex,
items: list,
onFocusChange: notify,
} = live.current;
const count = current.planes.length;
if (count === 0) return;
if (mode === 'page') {
const instance = lenis.current;
if (instance) target.current = instance.scroll / Math.max(perIndex, 1);
}
const previous = position.current;
position.current = damp(position.current, target.current, POSITION_SMOOTHING, dt);
// Index steps per second, smoothed by the same damp that produced the
// position, so the wave can never lead the geometry it is bending.
const instant = dt > 0 ? (position.current - previous) / dt : 0;
velocity.current = damp(velocity.current, instant, 0.002, dt);
const normalized = clamp(velocity.current / VELOCITY_REFERENCE, -1, 1);
strength.current = damp(
strength.current,
hovered.current && !reduced ? 1 : 0,
STRENGTH_SMOOTHING,
dt,
);
const s = intro.current;
const speed = Math.abs(normalized);
const liveRadius = baseRadius * (1 + RADIUS_VELOCITY_GAIN * speed);
const liveSpacing = baseSpacing * (1 + SPACING_VELOCITY_GAIN * speed);
const angularScale = 1 + ANGULAR_VELOCITY_GAIN * speed;
const deform = reduced
? 0
: clamp(velocity.current * DEFORM_PER_INDEX, -MAX_DEFORM, MAX_DEFORM);
// The flat-row layout spans the frustum's visible WIDTH at z = 0. Sizing
// it from window.innerWidth, or from the visible height, leaves the row
// short on wide viewports and clipped on narrow ones.
const visibleWidth =
2 * Math.tan((CAMERA_FOV * Math.PI) / 360) * CAMERA_Z * current.camera.aspect * 0.86;
const step = visibleWidth / Math.max(count - 1, 1);
const rowWidth = (count - 1) * step;
const mx = pointer.current.active ? pointer.current.ndcX : 0;
const my = pointer.current.active ? pointer.current.ndcY : 0;
for (let i = 0; i < count; i++) {
const plane = current.planes[i];
if (!plane) continue;
// The one call that makes a finite array behave like an infinite ring:
// item 0 is one step BEFORE item 18, not eighteen steps after it.
const offset = wrappedOffset(i, position.current, count);
const angle = (offset / count) * Math.PI * 2 * baseTurns * angularScale;
const y = -offset * liveSpacing;
plane.mesh.position.x = (i * step - rowWidth / 2) * (1 - s);
plane.mesh.position.y = y * s;
plane.mesh.scale.setScalar(lerp(0.62, 1, s));
const u = plane.uniforms;
u.uSpiralAngle.value = angle;
u.uRadius.value = liveRadius;
u.uIntro.value = s;
u.uDeform.value = deform;
u.uOpacity.value = alpha;
u.uMouseStrength.value = strength.current;
u.uMouseNdc.value.set(mx, my);
// exp(-d²): 1.000 at the centre, 0.368 one step out, 0.018 two, 0.0001
// three. Continuous, branchless, and the reason the drum reads as
// analogue rather than as a list with a selected row.
u.uFocus.value = lerp(1, gaussian(offset), s);
// Transparent meshes are sorted by three using the CPU position, which
// for this drum is (0, y, 0) for every plane. Reconstructing the depth
// the shader will actually produce and painting back to front is the
// only way the far side of the ring stays behind the near side.
const worldZ = liveRadius * Math.cos(angle) * s;
plane.mesh.renderOrder = Math.round(-worldZ * 1000);
}
const nextFocus = wrapIndex(Math.round(position.current), count);
if (nextFocus !== focusedRef.current) {
focusedRef.current = nextFocus;
setFocused(nextFocus);
const item = list[nextFocus];
if (item) notify?.(item, nextFocus);
}
current.renderer.render(current.scene, current.camera);
},
ready && inView && visible,
);
// #endregion critical
const focusedItem = items[wrapIndex(focused, Math.max(items.length, 1))];
const fallback = useMemo(
() => (
<div className="flex h-full w-full items-center justify-center">
<ol className="flex max-w-full gap-px overflow-hidden">
{items.slice(0, 7).map((item, index) => (
<li
key={item.id}
className="flex h-28 w-10 shrink-0 items-end justify-center pb-2 text-[10px] text-fg/70"
style={{
background: `linear-gradient(160deg, hsl(${item.hue ?? (index * 37) % 360} 55% 14%), hsl(${(item.hue ?? (index * 37) % 360) + 44} 68% 55%))`,
}}
>
{String(index + 1).padStart(2, '0')}
</li>
))}
</ol>
</div>
),
[items],
);
return (
<div className={cn('relative h-full w-full overflow-hidden', className)}>
<div
ref={host}
className="absolute inset-0 touch-none select-none"
style={{ cursor: drive === 'pointer' ? 'grab' : 'default' }}
/>
{!supported ? fallback : null}
{focusedItem ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-4">
<p className="text-sm tracking-tight text-fg">{focusedItem.title}</p>
<p className="meta">{focusedItem.meta ?? `${focused + 1} / ${items.length}`}</p>
</div>
) : null}
</div>
);
}
/* ==========================================================================
Cylinder Gallery — GLSL, as TypeScript template literals.
--------------------------------------------------------------------------
Shipped as `.ts` and not `.glsl` on purpose. A `.glsl` import forces every
consumer to install and configure a bundler plugin before the component
will compile; a template literal is a plain module that every bundler
already understands.
Everything visible in this component happens in the vertex shader. The
camera never moves and the CPU never touches a vertex — the geometry stays
a flat `PlaneGeometry` at the origin and the GPU wraps it around a drum
every frame, which is what makes the radius, the intro morph and the
velocity wave all free to animate.
========================================================================== */
export const cylinderGalleryVertexShader = /* glsl */ `
uniform float uSpiralAngle;
uniform float uRadius;
uniform float uIntro;
uniform float uDeform;
uniform vec2 uMouseNdc;
uniform float uMouseStrength;
uniform float uTextureAspect;
uniform float uPlaneAspect;
uniform float uOverscan;
varying vec2 vCoverUv;
varying float vBulge;
void main() {
// Barrel. sin() over y draws the top and bottom edges in slightly, so the
// plane reads as a piece of a sphere rather than a rolled sheet of paper.
// For a 1.95-tall plane y is in [-0.975, 0.975], where sin() is still
// monotonic — push the plane taller than 2 units and the edges fold back.
float barrelY = sin(position.y);
// The drum, in two lines. The vertex's own x IS the arc offset, so one unit
// of plane width maps to exactly one radian: a wider plane wraps further
// round. No CylinderGeometry, no pre-bent UVs, no baked mesh.
float angle = uSpiralAngle + position.x;
vec3 ringPos = vec3(uRadius * sin(angle), barrelY, uRadius * cos(angle));
// The other layout: a flat row, planes side by side, facing the camera.
vec3 rowPos = vec3(position.x, barrelY, 0.0);
// One scalar decides which of the two you are looking at, and every value
// in between is a valid frame. Note this is a mix of positions, NOT
// "uRadius scaled to zero": at radius 0 every vertex collapses onto the
// origin and the row is nineteen invisible slivers.
vec3 pos = mix(rowPos, ringPos, uIntro);
// Travelling wave, driven by signed scroll velocity. Sampled from
// 'position' — the ORIGINAL flat coordinates — and not from 'pos', so the
// ripple is locked to the picture rather than to the drum. It reads as the
// photograph itself flexing, which is the intended illusion. The + 100.0
// is a phase offset so the wave is not symmetric about the plane's centre.
pos.z += sin(position.y * 3.0 + 100.0) * uDeform * 0.07;
pos.y += sin(position.x * 3.0) * uDeform * 0.04;
// Two projections per vertex, deliberately. This first one is a
// measurement — where does this vertex land on screen right now? — which is
// what lets the pointer be compared against a bent mesh with no CPU work
// and no picking buffer at all.
vec4 probe = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
vec2 ndc = probe.xy / probe.w;
float mouseDist = length(ndc - uMouseNdc);
float bulge = exp(-mouseDist * mouseDist * 5.0) * uMouseStrength;
pos.z += bulge * 0.5;
vBulge = bulge;
// object-fit: cover, in UV space, evaluated once per vertex instead of once
// per fragment. Work outward from the centre so the crop stays centred.
vec2 centered = uv - 0.5;
if (uTextureAspect > uPlaneAspect) {
centered.x *= uPlaneAspect / uTextureAspect; // texture wider: crop the sides
} else {
centered.y *= uTextureAspect / uPlaneAspect; // texture taller: crop top and bottom
}
// Overscan. An exactly-fitted texture shows its own border the moment the
// wave or the bulge moves a vertex; zooming in leaves the crop margin to
// give back.
centered /= uOverscan;
vCoverUv = centered + 0.5;
// ...and the second projection, which is the one that actually ships the
// vertex, after every displacement above has been applied.
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
export const cylinderGalleryFragmentShader = /* glsl */ `
uniform sampler2D uTexture;
uniform float uFocus;
uniform float uOpacity;
varying vec2 vCoverUv;
varying float vBulge;
void main() {
// The bulge does not only lift the surface toward the camera, it magnifies
// it: pushing the centred UV outward by the same scalar turns a bump into
// a lens, which is the difference between "warped" and "under glass".
vec2 centered = vCoverUv - 0.5;
vec4 texel = texture2D(uTexture, vCoverUv + centered * vBulge * 0.12);
// Out-of-focus planes lose colour and light rather than blurring. A real
// blur needs a second pass and a render target; desaturating toward
// luminance costs one dot product and reads the same at this scale.
float grey = dot(texel.rgb, vec3(0.2126, 0.7152, 0.0722));
vec3 color = mix(vec3(grey), texel.rgb, mix(0.18, 1.0, uFocus));
color *= mix(0.38, 1.0, uFocus);
gl_FragColor = vec4(color, texel.a * uOpacity * mix(0.22, 1.0, uFocus));
// ShaderMaterial gets no automatic output conversion — three only injects
// this chunk into its own materials. Without it every colour ships raw
// linear values into an sRGB framebuffer and the whole scene looks washed.
#include <colorspace_fragment>
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
/* ==========================================================================
Pointer drag with capture, a dead zone, and a click gate.
--------------------------------------------------------------------------
Three things every hand-rolled drag gets wrong:
1. No pointer capture. Drag fast, leave the element, and `pointermove`
stops firing — the track sticks mid-throw. `setPointerCapture` keeps
events coming to the origin element until release.
2. No dead zone. Without one, a 1 px wobble during a click is a drag, and
the drag handler fights the click handler on every tap.
3. The click still fires. After a real drag, the browser dispatches
`click` on whatever is under the pointer, so throwing a carousel opens
whichever slide you happen to release over. Swallowing that one click
in the capture phase is the fix; `onDragEnd`'s `moved` flag tells the
caller whether it happened.
========================================================================== */
export interface DragState {
/** Pixels from the press point. */
dx: number;
dy: number;
/** Pixels since the previous move event. */
deltaX: number;
deltaY: number;
/** Viewport pixels. */
clientX: number;
clientY: number;
}
export interface UseDragOptions {
/** Pixels of travel before it counts as a drag. Default 4. */
threshold?: number;
/** Ignore the wrong mouse buttons / a secondary touch. Default true. */
primaryOnly?: boolean;
onDragStart?: (state: DragState) => void;
onDrag?: (state: DragState) => void;
/** `moved` is false for a tap: the caller should treat it as a click. */
onDragEnd?: (state: DragState, moved: boolean) => void;
enabled?: boolean;
}
export function useDrag(
ref: RefObject<HTMLElement | null>,
options: UseDragOptions = {},
): { current: boolean } {
const { threshold = 4, primaryOnly = true, enabled = true } = options;
const dragging = useRef(false);
const handlers = useRef(options);
handlers.current = options;
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastY = 0;
let passed = false;
let swallowClick = false;
const stateAt = (clientX: number, clientY: number): DragState => ({
dx: clientX - startX,
dy: clientY - startY,
deltaX: clientX - lastX,
deltaY: clientY - lastY,
clientX,
clientY,
});
const onPointerDown = (e: PointerEvent) => {
if (primaryOnly && !e.isPrimary) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
pointerId = e.pointerId;
startX = lastX = e.clientX;
startY = lastY = e.clientY;
passed = false;
// Capture on press, not on threshold: by the time the threshold is
// crossed the pointer may already be outside the element.
el.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (pointerId !== e.pointerId) return;
const state = stateAt(e.clientX, e.clientY);
if (!passed) {
if (Math.hypot(state.dx, state.dy) < threshold) return;
passed = true;
dragging.current = true;
handlers.current.onDragStart?.(state);
}
handlers.current.onDrag?.(state);
lastX = e.clientX;
lastY = e.clientY;
};
const finish = (e: PointerEvent) => {
if (pointerId !== e.pointerId) return;
const state = stateAt(e.clientX, e.clientY);
const moved = passed;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
pointerId = null;
passed = false;
dragging.current = false;
swallowClick = moved;
handlers.current.onDragEnd?.(state, moved);
};
// Capture phase, so it runs before any child's own click handler.
const onClick = (e: MouseEvent) => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopPropagation();
};
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', finish);
el.addEventListener('pointercancel', finish);
el.addEventListener('click', onClick, true);
return () => {
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', finish);
el.removeEventListener('pointercancel', finish);
el.removeEventListener('click', onClick, true);
if (pointerId !== null && el.hasPointerCapture(pointerId)) {
el.releasePointerCapture(pointerId);
}
dragging.current = false;
};
}, [ref, threshold, primaryOnly, enabled]);
return dragging;
}
'use client';
import { useEffect, useRef, useState, type RefObject } from 'react';
/* ==========================================================================
IntersectionObserver, sharing one observer per option set.
--------------------------------------------------------------------------
A hundred cards each constructing their own observer is a hundred observers
the compositor has to service. Keying a shared observer by its options
collapses that to one per distinct configuration — in practice, two or
three for the whole site.
========================================================================== */
export interface InViewState {
inView: boolean;
/** 0-1. The preview slot allocator ranks candidates by this. */
ratio: number;
}
export interface UseInViewOptions {
root?: Element | null;
rootMargin?: string;
/** Single number or list, same meaning as the native option. */
threshold?: number | number[];
/** Stop observing after the first time it becomes visible. */
once?: boolean;
}
type Entry = {
observer: IntersectionObserver;
handlers: Map<Element, (e: IntersectionObserverEntry) => void>;
};
const pools = new Map<string, Entry>();
function poolFor(options: UseInViewOptions): Entry {
const key = JSON.stringify({
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
});
const existing = pools.get(key);
if (existing) return existing;
const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) handlers.get(entry.target)?.(entry);
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
},
);
const created: Entry = { observer, handlers };
pools.set(key, created);
return created;
}
export function useInView(
ref: RefObject<Element | null>,
options: UseInViewOptions = {},
): InViewState {
const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 });
const { root = null, rootMargin = '0px', once = false } = options;
const thresholdKey = JSON.stringify(options.threshold ?? 0);
const done = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
if (once && done.current) return;
const pool = poolFor({
root,
rootMargin,
threshold: JSON.parse(thresholdKey) as number | number[],
});
const handle = (entry: IntersectionObserverEntry) => {
setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio });
if (once && entry.isIntersecting) {
done.current = true;
pool.handlers.delete(el);
pool.observer.unobserve(el);
}
};
pool.handlers.set(el, handle);
pool.observer.observe(el);
return () => {
pool.handlers.delete(el);
pool.observer.unobserve(el);
};
}, [ref, root, rootMargin, thresholdKey, once]);
return state;
}
'use client';
import Lenis from 'lenis';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One Lenis instance, driven by whichever clock is already running.
--------------------------------------------------------------------------
Two failure modes this exists to prevent:
1. Two rAF loops. Lenis self-drives via `autoRaf` by default. If GSAP's
ticker is also running, scroll position advances on one clock while
ScrollTrigger reads it on another, and pinned sections jitter by a
frame. Passing a `ticker` disables `autoRaf` and hands Lenis to that
clock instead — the documented integration.
2. Two Lenis instances. Mount a second one and both listen on `wheel`,
both call `preventDefault`, and scrolling runs at double speed. The
module-level refcount below makes a second mount reuse the first
instance rather than build a rival.
========================================================================== */
export interface LenisTicker {
add: (fn: (time: number) => void) => void;
remove: (fn: (time: number) => void) => void;
/**
* `threshold` is required, matching `gsap.ticker`, which types it as
* `number | boolean`. Declaring it optional here made the parameter
* `number | undefined` and no longer satisfiable by GSAP's own ticker.
*/
lagSmoothing?: (threshold: number, adjustedLag?: number) => void;
}
export interface UseLenisOptions {
/** Seconds to settle. Ignored when `easing` drives duration. */
duration?: number;
easing?: (t: number) => number;
orientation?: 'vertical' | 'horizontal';
smoothWheel?: boolean;
wheelMultiplier?: number;
touchMultiplier?: number;
infinite?: boolean;
/**
* Drive Lenis from this clock instead of its own rAF. Pass `gsap.ticker`
* when the page also uses ScrollTrigger.
*/
ticker?: LenisTicker;
/** Called on every scroll frame. Held in a ref, so it needs no memo. */
onScroll?: (lenis: Lenis) => void;
/** Skip entirely — for `prefers-reduced-motion`, or on touch devices. */
enabled?: boolean;
}
let shared: Lenis | null = null;
let refCount = 0;
export function useLenis(options: UseLenisOptions = {}): { current: Lenis | null } {
const {
duration,
easing,
orientation = 'vertical',
smoothWheel = true,
wheelMultiplier,
touchMultiplier,
infinite = false,
ticker,
onScroll,
enabled = true,
} = options;
const instance = useRef<Lenis | null>(null);
const onScrollRef = useRef(onScroll);
onScrollRef.current = onScroll;
useEffect(() => {
if (!enabled) return;
if (shared === null) {
shared = new Lenis({
...(duration === undefined ? {} : { duration }),
...(easing === undefined ? {} : { easing }),
orientation,
smoothWheel,
...(wheelMultiplier === undefined ? {} : { wheelMultiplier }),
...(touchMultiplier === undefined ? {} : { touchMultiplier }),
infinite,
// A ticker owns the clock; `autoRaf` would be the second one.
autoRaf: ticker === undefined,
});
}
const lenis = shared;
refCount += 1;
instance.current = lenis;
const handleScroll = () => onScrollRef.current?.(lenis);
lenis.on('scroll', handleScroll);
const update = (time: number) => lenis.raf(time * 1000); // ticker is in seconds
if (ticker) {
ticker.add(update);
// Default lag smoothing freezes GSAP's clock after a 500 ms stall while
// Lenis keeps its own time, so the two desync exactly when the page is
// busiest. 0 disables it.
ticker.lagSmoothing?.(0);
}
return () => {
lenis.off('scroll', handleScroll);
if (ticker) ticker.remove(update);
instance.current = null;
refCount -= 1;
if (refCount === 0) {
lenis.destroy();
shared = null;
}
};
}, [
duration,
easing,
orientation,
smoothWheel,
wheelMultiplier,
touchMultiplier,
infinite,
ticker,
enabled,
]);
return instance;
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
/* ==========================================================================
Ring index arithmetic.
--------------------------------------------------------------------------
Anything that loops — a drum carousel, an infinite marquee, a cylinder of
image planes — needs to answer "how far is item i from position p" in a way
that treats the sequence as a circle. A plain `i - p` says item 0 is 18 away
from item 18 in a list of 19, when really it is 1 step back.
========================================================================== */
/** True modulo. Unlike `%`, the result is never negative. */
export function mod(n: number, m: number): number {
return ((n % m) + m) % m;
}
/**
* Shortest signed distance from `position` to index `i` around a ring of
* `count` items. Result is in `(-count/2, count/2]`; negative means "behind".
*
* This one function is what makes a finite array behave like an infinite one.
*/
export function wrappedOffset(i: number, position: number, count: number): number {
if (count <= 0) return 0;
let offset = mod(i - position, count);
if (offset > count / 2) offset -= count;
return offset;
}
/** Wrap `value` into `[min, max)`, e.g. an angle into `[-PI, PI)`. */
export function wrapRange(value: number, min: number, max: number): number {
const span = max - min;
if (span <= 0) return min;
return mod(value - min, span) + min;
}
/** Wrap an index into `[0, count)`. Accepts fractional and negative input. */
export function wrapIndex(i: number, count: number): number {
return count <= 0 ? 0 : mod(i, count);
}
/**
* Shortest signed angular distance from `a` to `b` in radians, in `(-PI, PI]`.
* Prevents a rotation tween from taking the long way round.
*/
export function shortestAngle(a: number, b: number): number {
return wrapRange(b - a, -Math.PI, Math.PI);
}
API
Every parameter, its type and what happens when you change it.
function CylinderGallery(props: { items: CylinderGalleryItem[]; drive?: "pointer" | "page"; radius?: number; turns?: number; spacing?: number; segments?: number; pixelsPerIndex?: number; opacity?: number; paint?: (ctx: CanvasRenderingContext2D, item: CylinderGalleryItem, index: number, size: { width: number; height: number }) => void; onSelect?: (item: CylinderGalleryItem, index: number) => void; onFocusChange?: (item: CylinderGalleryItem, index: number) => void; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| items | CylinderGalleryItem[] | — | { id, title, meta?, hue? } per plane. One mesh and one generated texture each. |
| drive | "pointer" | "page" | = "pointer" | Wheel and drag inside the component, or document scroll via a shared infinite Lenis. |
| radius | number | = 2 | Drum radius in world units, before the velocity widening. |
| turns | number | = 3 | Full revolutions the ring spans across the whole list. |
| spacing | number | = 0.8 | World units of vertical travel per index step. |
| segments | number | = 64 | Plane subdivisions per axis. This is the resolution of the bend. |
| pixelsPerIndex | number | = 240 | Document scroll pixels per index step, in page mode only. |
| opacity | number | = 1 | Global alpha multiplier applied before the focus falloff. |
| paint | (ctx, item, index, size) => void | — | Override the generated plate. Receives a 2D context for a 512x1024 canvas. |
| onSelect | (item, index) => void | — | Tap on the drum. A drag never counts as a tap. |
| onFocusChange | (item, index) => void | — | Fires when a different index takes the centre. |
| className | string | — | Merged onto the wrapper via cn. |
How it works
The technique in order, not the code in order.
- 01
The vertex shader treats a vertex's own x as an arc offset:
angle = uSpiralAngle + position.x, thenpos.x = uRadius * sin(angle)andpos.z = uRadius * cos(angle), so one world unit of plane width wraps exactly one radian of drum.pos.y = sin(position.y)pulls the top and bottom edges in, which turns a rolled sheet into a barrel. - 02
Per frame and per plane,
wrappedOffset(i, position, count)returns the shortest signed distance from that plane to the focused index — the value that makes index 0 sit one step before index 18 rather than eighteen steps after it. That offset drivesuSpiralAngle = (offset / count) * 2π * turns, the vertical stacky = -offset * spacing, and the focus term. - 03
Focus is
gaussian(offset)=exp(-offset²): 1.0 at the centre, 0.368 one step out, 0.018 two, 0.0001 three. The fragment shader mixes toward luminance and dims by that weight, so attention falls off continuously instead of one plane being flagged "selected". - 04
A single
uIntroscalar lerps between two complete layouts inside the shader —mix(rowPos, ringPos, uIntro)— whererowPosis a flat facing row andringPosis the drum. Every intermediate value is a valid frame, and the CPU lerps the mesh x/y positions with the same scalar so the two layouts stay consistent. - 05
Signed scroll velocity feeds
uDeform, which addssin(position.y * 3.0 + 100.0) * uDeform * 0.07to z andsin(position.x * 3.0) * uDeform * 0.04to y. It samplesposition— the original flat coordinates — not the already-bentpos, so the ripple is locked to the picture and reads as the image flexing rather than the drum wobbling. - 06
Pointer proximity is measured inside the vertex shader by projecting each vertex once as a probe (
ndc = probe.xy / probe.w), takingexp(-mouseDist² * 5.0), lifting z by half of it and passing it to the fragment shader, which pushes the centred UV outward by the same weight so the bump also magnifies — a lens, not a bump.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| PLANE_WIDTH / PLANE_HEIGHT | = 1 / 1.95 | Width 1 because the bend uses the vertex's raw x as radians — at width 1 each plane spans exactly 1 rad of the drum, which is the whole reason no trigonometry is needed on the CPU. Height must stay under 2π/3 or sin(position.y) leaves its monotonic range and the barrel folds its own edges back through the surface. |
| CAMERA_FOV / CAMERA_Z | = 45 / 5 | The camera never animates, so these only set how much drum is in shot. Below ~35° the ring flattens into a row and the depth cue vanishes; above ~60° the near plane balloons and the barrel curve reads as a keystone error. |
| OVERSCAN | = 1.12 | The cover-fit UV is divided by this, zooming the crop in 12%. At exactly 1.0 the wave and the bulge push vertices past the sampled region and the texture's own clamped border smears along the edge. Beyond ~1.25 the crop eats the subject. |
| segments | = 64 | The bend is evaluated per vertex, so subdivision *is* the curve. Below ~24 the drum visibly becomes a folded card and the mouse bulge turns into a triangular tent; past ~128 you are paying vertex cost for a curve the pixel grid cannot resolve. |
| POSITION_SMOOTHING | = 0.0006 | Fraction of remaining distance left after one second. Larger (slower) and the drum lags visibly behind the wheel; much smaller and the velocity spike that drives uDeform never has time to build, so the wave disappears. |
| SNAP_DELAY_MS | = 170 | Idle time before the drum eases to a whole index. Under ~120 ms it fires between the discrete events of a single trackpad flick and fights the gesture; over ~250 ms the gallery feels like it forgot to settle. |
| MAX_DEFORM / DEFORM_PER_INDEX | = 1.5 / 0.42 | Deform is clamped to ±1.5 because the shader multiplies it by 0.07 world units — past that the planes intersect each other and the far side of the drum pokes through the near side. 0.42 sets how many index-steps per second count as full deflection. |
| RADIUS_VELOCITY_GAIN | = 0.5 | The drum widens up to 50% at full speed, which is what sells inertia. Above ~0.8 the nearest plane crosses the camera's near plane during a fast flick and clips away. |
| gaussian falloff | = 1 (exp(-offset²)) | A falloff of 1 keeps the immediate neighbours legible at 0.37 while the third plane out is effectively gone. Raising it dims the neighbours to black and the ring loses its context; lowering it flattens every plane to the same weight. |
| renderOrder scale | = -worldZ * 1000 | Reconstructed drum depth, scaled up so it survives rounding to an integer sort key. At a smaller scale adjacent planes collapse into the same bucket and flicker between frames. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The intro morph is skipped — uIntro is set to 1 on mount, so the drum is simply already assembled. The velocity wave is pinned to 0 and the pointer bulge never engages, so the surface stays flat. Navigation still works and still eases: wheel, drag and the snap-to-index tween are unchanged, because the user asked for less motion, not for a gallery that cannot move.
Cleanup
Removes the wheel and pointerenter/pointerleave listeners, kills the intro tween and any pending snap timeout plus its tween, removes every mesh from the scene, disposes each CanvasTexture by hand (they sit inside material.uniforms, where disposeObject cannot reach), runs disposeObject for the geometries and materials, disposes the shared geometry, then disposeRenderer — setAnimationLoop(null), dispose(), forceContextLoss() — and removes the canvas from the DOM. The frame loop reads a stage ref that is nulled first, so a queued frame after teardown is a no-op rather than a null context throw.
Definition of done
On mount the planes start as a flat row facing you and curl into a drum over about 1.6 s. Wheel or drag spins it endlessly in both directions with no seam and no jump at the ends; stop moving and it eases to centre one plane. The centred plane is full colour and the neighbours fade toward grey within two steps. Flick hard and the images ripple, the drum widens, and the ripple runs opposite ways depending on direction. Move the pointer over a plane and a soft lens follows it, magnifying rather than just denting. Unmount from React DevTools and the canvas leaves the DOM with no context-lost warning; mount and unmount twenty times and no context is dropped. With prefers-reduced-motion: reduce the drum is already assembled on the first frame and neither the ripple nor the lens ever appears.
Pitfalls
- Scaling
uRadiusfrom 0 to animate the intro. At radius 0 every vertex of every plane lands on the origin, so the "flat" state is nineteen invisible slivers stacked in one spot. Lerp between two position vectors —mix(rowPos, ringPos, uIntro)— and the flat state is a real layout you can hold. - Leaving
frustumCulledat its default. The CPU-side geometry is still a flat plane sitting atmesh.position; the visible shape is somewhere else entirely. three culls against that stale bounding sphere and planes that are plainly on screen blink out at the edges. - Trusting three's transparency sort. It orders transparent meshes by their object position, which for this drum is
(0, y, 0)for every single plane, so the far side of the ring paints over the near side at random. Reconstruct the depth the shader will produce (radius * cos(angle) * intro) and setrenderOrderfrom it each frame. - Expecting
disposeObjectto free the plate textures. It scans a material's own properties for anything withisTexture; these textures live two levels down insidematerial.uniforms, so it never sees them and nineteen 512x1024 canvases stay resident on the GPU after unmount. They are disposed explicitly from a tracked list. - Calling
renderer.dispose()and stopping there. That releases GPU objects but keeps the WebGL context alive; Chromium caps a renderer process at roughly 16 and silently kills the oldest, so a docs page that mounts several of these blanks the ones above it.disposeRenderercallsforceContextLoss. - Attaching the wheel listener passively. Without
preventDefaultthe drum spins and the host page scrolls at the same time, which reads as two gestures fighting; the listener is registered with{ passive: false }. - Sampling the deformation wave from the already-bent
posinstead of the originalposition. The ripple then travels with the drum rotation rather than staying pinned to the image, and the effect looks like a rendering fault instead of the picture flexing. - Sizing the flat-row layout from
window.innerWidth. The row has to span the frustum's visible width at z = 0, which is2 * tan(fov/2) * cameraZ * aspect— the container width in pixels has no fixed relationship to that, so the row overshoots on wide viewports and clips on narrow ones. - Omitting
#include <colorspace_fragment>from aShaderMaterial. three only injects output conversion into its own materials, so raw linear values ship into an sRGB framebuffer and the plates render visibly washed out next to the CSS around them. - Rebuilding the scene when the
itemsarray identity changes. Any caller who writes the list inline hands over a new array every render, which would tear down nineteen textures and a GL context on every keystroke. The setup effect keys on a string built from id, title and hue.
Notes
- Textures are drawn at mount to a 512x1024 canvas per item — a hue ramp, hairline bands and a numeral. The bands are not decoration: a smooth gradient hides every deformation the shader applies, so the plate needs structure for the wave and the bulge to bend.
- All nineteen materials are separate
ShaderMaterialinstances because each needs its own uniforms, but three keys its program cache on the shader source, so they share a single compiled program. drive: "page"opts into a shared refcounted Lenis withinfinite: true, which is the technique in its original form — the gallery *is* the page. The defaultdrive: "pointer"keeps every gesture inside the component's own box.