Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll

Foundation Blocks (07)

  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Spiral Gallery

Spiral Gallery

An infinite carousel of images staggered along a spiraling tube that scrolls indefinitely.

https://atelier-ui.com/spiral-gallery
radius
5.00
tile-height
2.45
tile-aspect
1.50
tile-count
16
vertical-spacing
0.95
turn-angle
46
tile-rotation
1.00
corner-radius
0.05
curve
0.10
auto-scroll
0.10
easing
0.10
input-speed
1.10
drag
scroll-spread
0.50
scroll-growth
0.65
lens-blur
0.40
scale
1.30
reveal
reveal-duration
2.0
focus-duration
2.1
focus-scale
0.70
radius
5.00
tile-height
2.45
tile-aspect
1.50
tile-count
16
vertical-spacing
0.95
turn-angle
46
tile-rotation
1.00
corner-radius
0.05
curve
0.10
auto-scroll
0.10
easing
0.10
input-speed
1.10
drag
scroll-spread
0.50
scroll-growth
0.65
lens-blur
0.40
scale
1.30
reveal
reveal-duration
2.0
focus-duration
2.1
focus-scale
0.70
Prompt
Add Atelier's Spiral Gallery to my app.

If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/spiral-gallery
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.

Props: radius={5} tileHeight={2.45} tileAspect={1.5} tileCount={16} verticalSpacing={0.95} turnAngle={46} tileRotation={1} cornerRadius={0.05} curve={0.1} autoScroll={0.1} easing={0.1} inputSpeed={1.1} drag={true} scrollSpread={0.5} scrollGrowth={0.65} lensBlur={0.4} scale={1.3} reveal={true} revealDuration={2} focusDuration={2.1} focusScale={0.7}

Just into your favorite coding agent and let Atelier do its magic. Live props are included.


This command will install all the dependencies this component uses.

npx shadcn@latest add @atelier/spiral-gallery

Install the dependencies first, then feel free to copy the files into your project as you see fit.

npm install three @types/three @react-three/fiber @react-three/drei motion
spiral-gallery.tsx
"use client"

import { shaderMaterial, useFBO, useTexture } from "@react-three/drei"
import { createPortal, extend, type ThreeElement, useFrame, useThree } from "@react-three/fiber"
import type { Easing } from "motion"
import { animate, useMotionValue, useSpring, wrap } from "motion/react"
import {
    type ComponentRef,
    type ReactNode,
    type RefObject,
    useCallback,
    useEffect,
    useLayoutEffect,
    useMemo,
    useRef,
    useState,
} from "react"
import * as THREE from "three"
import { MathUtils } from "three"
import { WebglScene, type WebglSceneProps } from "../webgl-scene/webgl-scene"

const WHEEL_STEP = 0.0025 as const
const DRAG_STEP = 0.005 as const
const DRAG_X_MULTIPLIER = 0.3 as const
const DRAG_THRESHOLD = 6 as const
const MAX_LAG = 1.5 as const
const REFERENCE_ASPECT = 16 / 9
const REVEAL_SCALE = 0.35 as const
const REVEAL_DELAY = 0.25 as const
const REVEAL_EASING = [0.4, 0.2, 0.15, 1] as Easing
const FOCUS_EASING = [0.7, 0.03, 0.26, 0.99] as Easing
const SLIDE_DURATION_RATIO = 0.4 as const
const FOCUS_FADE_KEYFRAMES = [0, 0.6, 1]
const FOCUS_GAP_RATIO = 0.06 as const
const ROW_TILE_ASPECT = 0.8 as const
const FOCUS_LENS_FLARE = 3 as const

const DEFAULT_PROPS = {
    radius: 4.5,
    tileHeight: 2.25,
    tileAspect: 1.5,
    tileCount: 16,
    verticalSpacing: 0.75,
    turnAngle: 46,
    tileRotation: 1,
    cornerRadius: 0,
    curve: 0.09,
    autoScroll: 0.1,
    easing: 0.1,
    input: "wheel" as SpiralInput,
    inputSpeed: 1.1,
    drag: true,
    scrollSpread: 0.5,
    scrollGrowth: 0.65,
    wave: 0.8,
    lensBlur: 0.24,
    reveal: true,
    revealDuration: 2,
    focusDuration: 1.6,
    focusScale: 0.7,
    autoScale: true,
    scale: 1.3,
}

type SpiralInput = "wheel" | "scroll" | "none"

type Bounds = {
    width: number
    height: number
}

type RowMetrics = {
    openAspect: number
    openWidth: number
    fitScale: number
    flatSpacing: number
}

type FrameState = {
    position: number
    tension: number
    presence: number
    spiral: number
    focus: number
    time: number
}

export type SpiralGalleryItem = {
    src: string
    alt: string
}

export type SpiralGalleryProps = {
    items: SpiralGalleryItem[]
    className?: string
} & Partial<typeof DEFAULT_PROPS> &
    Pick<WebglSceneProps, "mode" | "priority" | "zIndex" | "transparent" | "autoReflow">

type SpiralSceneProps = {
    sources: string[]
    surface: RefObject<HTMLElement | null>
    activeIndex: number | null
    onSelect: (index: number | null) => void
} & typeof DEFAULT_PROPS

type PlaneMesh = THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof SpiralTileMaterial>>

declare module "@react-three/fiber" {
    interface ThreeElements {
        spiralTileMaterial: ThreeElement<typeof SpiralTileMaterial>
        spiralLensBlurMaterial: ThreeElement<typeof SpiralLensBlurMaterial>
    }
}

const SpiralTileMaterial = shaderMaterial(
    {
        uMap: new THREE.Texture(),
        uTileSize: new THREE.Vector2(1, 1),
        uUvScale: new THREE.Vector2(1, 1),
        uUvOffset: new THREE.Vector2(0, 0),
        uRadius: 0,
        uCurve: 0,
        uOpacity: 1,
        uWave: 0,
        uTime: 0,
    },
    /* glsl */ `
        uniform float uCurve;
        uniform float uWave;
        uniform float uTime;
        varying vec2 vUv;
        void main() {
            vUv = uv;
            vec3 transformed = position;
            transformed.z -= uCurve * position.x * position.x;
            transformed.z -= (0.5 + 0.5 * cos(position.x * 1.1 + uTime * 1.5)) * uWave;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
        }
    `,
    /* glsl */ `
        uniform sampler2D uMap;
        uniform vec2 uTileSize;
        uniform vec2 uUvScale;
        uniform vec2 uUvOffset;
        uniform float uRadius;
        uniform float uOpacity;
        varying vec2 vUv;

        float sdRoundBox(vec2 point, vec2 halfSize, float radius) {
            vec2 corner = abs(point) - halfSize + radius;
            return min(max(corner.x, corner.y), 0.0) + length(max(corner, 0.0)) - radius;
        }

        void main() {
            vec2 baseUv = uUvOffset + vUv * uUvScale;
            vec4 texel = texture2D(uMap, baseUv);

            vec2 point = (vUv - 0.5) * uTileSize;
            vec2 halfSize = uTileSize * 0.5;
            float radius = min(uRadius, min(halfSize.x, halfSize.y));
            float boxDistance = sdRoundBox(point, halfSize, radius);
            float boxAntialias = fwidth(boxDistance);
            float mask = smoothstep(boxAntialias, -boxAntialias, boxDistance);

            gl_FragColor = vec4(texel.rgb, texel.a * mask * uOpacity);
        }
    `,
)

const SpiralLensBlurMaterial = shaderMaterial(
    {
        uScene: new THREE.Texture(),
        uStrength: 0,
        uRadius: 0.18,
        uSmoothness: 0.5,
        uDispersion: 0.35,
    },
    /* glsl */ `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = vec4(position.xy, 0.0, 1.0);
        }
    `,
    /* glsl */ `
        uniform sampler2D uScene;
        uniform float uStrength;
        uniform float uRadius;
        uniform float uSmoothness;
        uniform float uDispersion;

        varying vec2 vUv;

        const int SAMPLES = 24;

        void main() {
            vec2 toCenter = vUv - 0.5;
            float distanceFromCenter = length(toCenter);

            float mask = smoothstep(uRadius, uRadius + uSmoothness, distanceFromCenter);
            float amount = mask * mask * uStrength;

            if (amount <= 0.0) {
                gl_FragColor = texture2D(uScene, vUv);
                return;
            }

            vec3 color = vec3(0.0);
            float alpha = 0.0;
            float total = 0.0;

            for (int sampleIndex = 0; sampleIndex < SAMPLES; sampleIndex++) {
                float progress = float(sampleIndex) / float(SAMPLES - 1);
                float weight = 1.0 - progress * 0.6;

                float scale = 1.0 - amount * progress;
                float spread = uDispersion * amount * progress;

                vec4 mid = texture2D(uScene, 0.5 + toCenter * scale);
                color.r += texture2D(uScene, 0.5 + toCenter * (scale + spread)).r * weight;
                color.g += mid.g * weight;
                color.b += texture2D(uScene, 0.5 + toCenter * (scale - spread)).b * weight;
                alpha += mid.a * weight;

                total += weight;
            }

            gl_FragColor = vec4(color / total, alpha / total);
        }
    `,
)

extend({ SpiralTileMaterial, SpiralLensBlurMaterial })

function useSurfaceBounds(surface: RefObject<HTMLElement | null>) {
    const bounds = useRef<Bounds>({ width: 0, height: 0 })

    useLayoutEffect(() => {
        const element = surface.current
        if (!element) return

        const measure = () => {
            const rect = element.getBoundingClientRect()
            bounds.current.width = rect.width
            bounds.current.height = rect.height
        }

        measure()
        const observer = new ResizeObserver(measure)
        observer.observe(element)
        return () => observer.disconnect()
    }, [surface])

    return bounds
}

type PostProcessingProps = {
    bounds: RefObject<Bounds>
    strength: RefObject<number>
    children: ReactNode
}

function PostProcessing({ bounds, strength, children }: PostProcessingProps) {
    const gl = useThree((state) => state.gl)
    const camera = useThree((state) => state.camera)
    const content = useMemo(() => new THREE.Scene(), [])
    const fbo = useFBO(1, 1, { samples: 4 })
    const blurRef = useRef<InstanceType<typeof SpiralLensBlurMaterial>>(null)

    useFrame(() => {
        if (blurRef.current) {
            blurRef.current.uStrength = strength.current
        }

        const { width, height } = bounds.current
        if (width === 0 || height === 0) return

        const pixelRatio = gl.getPixelRatio()
        const fboWidth = Math.max(1, Math.ceil(width * pixelRatio))
        const fboHeight = Math.max(1, Math.ceil(height * pixelRatio))
        if (fbo.width !== fboWidth || fbo.height !== fboHeight) {
            fbo.setSize(fboWidth, fboHeight)
        }

        const previousClearAlpha = gl.getClearAlpha()
        gl.setRenderTarget(fbo)
        gl.setClearAlpha(0)
        gl.clear()
        gl.render(content, camera)
        gl.setRenderTarget(null)
        gl.setClearAlpha(previousClearAlpha)
    }, -1)

    return (
        <>
            {createPortal(children, content)}

            <mesh frustumCulled={false}>
                <planeGeometry args={[2, 2]} />
                <spiralLensBlurMaterial
                    ref={blurRef}
                    key={SpiralLensBlurMaterial.key}
                    uScene={fbo.texture}
                    uStrength={0}
                    transparent
                    premultipliedAlpha
                    depthTest={false}
                    depthWrite={false}
                />
            </mesh>
        </>
    )
}

function surfaceScale(bounds: Bounds, autoScale: boolean) {
    if (!autoScale || bounds.height === 0) return 1
    return Math.min(1, bounds.width / bounds.height / REFERENCE_ASPECT)
}

function rowZoom(camera: THREE.PerspectiveCamera, focusScale: number, tileHeight: number) {
    const visibleHeight = 2 * Math.tan(MathUtils.degToRad(camera.fov) / 2) * camera.position.z
    const visibleWidth = visibleHeight * camera.aspect

    return (Math.min(visibleHeight, visibleWidth / ROW_TILE_ASPECT) * focusScale) / tileHeight
}

function coverCrop(material: PlaneMesh["material"], imageAspect: number, tileAspect: number) {
    let cropX = 1
    let cropY = imageAspect / tileAspect

    if (imageAspect > tileAspect) {
        cropX = tileAspect / imageAspect
        cropY = 1
    }

    material.uUvScale.set(cropX, cropY)
    material.uUvOffset.set((1 - cropX) / 2, (1 - cropY) / 2)
}

function SpiralScene({
    sources,
    surface,
    activeIndex,
    onSelect,
    radius,
    tileHeight,
    tileAspect,
    tileCount,
    verticalSpacing,
    turnAngle,
    tileRotation,
    cornerRadius,
    curve,
    autoScroll,
    easing,
    input,
    inputSpeed,
    drag,
    scrollSpread,
    scrollGrowth,
    wave,
    lensBlur,
    reveal,
    revealDuration,
    focusDuration,
    focusScale,
    autoScale,
    scale,
}: SpiralSceneProps) {
    const textures = useTexture(sources)
    const bounds = useSurfaceBounds(surface)
    const camera = useThree((state) => state.camera)
    const fitRef = useRef<THREE.Group>(null)
    const groupRef = useRef<THREE.Group>(null)
    const meshRefs = useRef<(PlaneMesh | null)[]>([])
    const target = useMotionValue(0)
    const scroll = useSpring(target, { visualDuration: easing, bounce: 0 })
    const progress = useRef({ presence: reveal ? 0 : 1, spiral: reveal ? 0 : 1, focus: 0 })
    const pointer = useRef({ dragging: false, moved: false, hovering: false })
    const animating = useRef(reveal)
    const tensionRef = useRef(0)
    const blurRef = useRef(lensBlur)
    const lastScrollY = useRef<number | null>(null)
    const tileCountRef = useRef(tileCount)
    tileCountRef.current = tileCount

    const applyCursor = useCallback(() => {
        const element = surface.current
        if (!element) return

        let cursor = ""

        if (drag && activeIndex === null) {
            cursor = "grab"
        }

        if (pointer.current.hovering) {
            cursor = "pointer"
        }

        if (pointer.current.dragging) {
            cursor = "grabbing"
        }

        element.style.cursor = cursor
    }, [surface, drag, activeIndex])

    const select = useCallback(
        (index: number | null) => {
            if (pointer.current.moved || animating.current) return
            onSelect(index === activeIndex ? null : index)
        },
        [onSelect, activeIndex],
    )

    useEffect(() => {
        applyCursor()
    }, [applyCursor])

    const width = tileHeight * tileAspect
    const angleStep = MathUtils.degToRad(turnAngle)
    const half = tileCount / 2
    const bandSpacing = radius * angleStep

    const tiles = useMemo(() => {
        return Array.from({ length: tileCount }, (_, index) => {
            const texture = textures[index % textures.length]
            const image = texture.image as HTMLImageElement

            return {
                texture,
                imageAspect: image.width / image.height,
                uvScale: new THREE.Vector2(1, 1),
                uvOffset: new THREE.Vector2(0, 0),
            }
        })
    }, [textures, tileCount])

    useEffect(() => {
        const element = surface.current
        if (!element || input !== "wheel" || activeIndex !== null) return

        const onWheel = (event: WheelEvent) => {
            event.preventDefault()
            target.set(target.get() + event.deltaY * WHEEL_STEP * inputSpeed)
        }
        element.addEventListener("wheel", onWheel, { passive: false })
        return () => element.removeEventListener("wheel", onWheel)
    }, [surface, target, input, inputSpeed, activeIndex])

    useEffect(() => {
        const element = surface.current
        if (!element || !drag || activeIndex !== null) return

        const origin = { x: 0, y: 0, target: 0 }

        const beginDrag = (event: PointerEvent) => {
            pointer.current.dragging = true
            pointer.current.moved = false
            origin.x = event.clientX
            origin.y = event.clientY
            origin.target = target.get()
            applyCursor()
        }

        const moveDrag = (event: PointerEvent) => {
            if (!pointer.current.dragging) return

            const sideways = event.clientX - origin.x
            const vertical = event.clientY - origin.y

            if (Math.abs(sideways) > DRAG_THRESHOLD || Math.abs(vertical) > DRAG_THRESHOLD) {
                pointer.current.moved = true
            }

            target.set(
                origin.target + (vertical - sideways * DRAG_X_MULTIPLIER) * DRAG_STEP * inputSpeed,
            )
        }

        const endDrag = () => {
            pointer.current.dragging = false
            applyCursor()
        }

        element.addEventListener("pointerdown", beginDrag)
        window.addEventListener("pointermove", moveDrag)
        window.addEventListener("pointerup", endDrag)
        window.addEventListener("pointercancel", endDrag)

        return () => {
            endDrag()
            element.removeEventListener("pointerdown", beginDrag)
            window.removeEventListener("pointermove", moveDrag)
            window.removeEventListener("pointerup", endDrag)
            window.removeEventListener("pointercancel", endDrag)
        }
    }, [surface, target, drag, inputSpeed, applyCursor, activeIndex])

    useEffect(() => {
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.key === "Escape") select(null)
        }

        window.addEventListener("keydown", onKeyDown)
        return () => window.removeEventListener("keydown", onKeyDown)
    }, [select])

    useEffect(() => {
        function revealAnimation() {
            const group = groupRef.current
            if (!group) return

            if (!reveal) {
                group.scale.setScalar(1)
                progress.current.presence = 1
                progress.current.spiral = 1
                animating.current = false
                return
            }

            group.scale.setScalar(REVEAL_SCALE)
            progress.current.presence = 0
            progress.current.spiral = 0
            animating.current = true

            const settings = {
                duration: revealDuration,
                ease: REVEAL_EASING,
                delay: revealDuration * REVEAL_DELAY,
                at: 0,
            }

            const controls = animate([
                [group.scale, { x: 1, y: 1, z: 1 }, settings],
                [progress.current, { presence: 1, spiral: 1 }, settings],
                [target, target.get() + tileCountRef.current, settings],
            ])

            controls.then(() => {
                animating.current = false
            })

            return () => {
                controls.stop()
                animating.current = false
            }
        }

        return revealAnimation()
    }, [reveal, revealDuration, target])

    useEffect(() => {
        const unfolded = progress.current.focus === 1
        if (activeIndex === null && !unfolded) return

        function focusAnimation() {
            const from = target.get()
            const count = tileCountRef.current

            if (activeIndex !== null && unfolded) {
                const settings = {
                    duration: focusDuration * SLIDE_DURATION_RATIO,
                    ease: FOCUS_EASING,
                }
                const travel = wrap(0, count, activeIndex - from + count / 2) - count / 2
                const controls = animate(target, from + travel, settings)

                return () => controls.stop()
            }

            const settings = { duration: focusDuration, ease: FOCUS_EASING, at: 0 }
            const opening = activeIndex !== null
            let travel = count

            if (opening) {
                travel += wrap(0, count, activeIndex - from)
            }

            const controls = animate([
                [progress.current, { spiral: opening ? 0 : 1, focus: opening ? 1 : 0 }, settings],
                [
                    progress.current,
                    { presence: [1, 0, 1] },
                    { ...settings, times: FOCUS_FADE_KEYFRAMES },
                ],
                [target, from + travel, settings],
            ])

            animating.current = true
            controls.then(() => {
                animating.current = false
            })

            return () => {
                controls.stop()
                animating.current = false
            }
        }

        return focusAnimation()
    }, [activeIndex, focusDuration, target])

    const advanceScroll = useCallback(
        (step: number) => {
            const scrollY = window.scrollY
            const scrolled = scrollY - (lastScrollY.current ?? scrollY)
            lastScrollY.current = scrollY

            if (activeIndex !== null) return

            if (input === "scroll") {
                target.set(target.get() + scrolled * WHEEL_STEP * inputSpeed)
            }

            if (!pointer.current.dragging) {
                target.set(target.get() + autoScroll * step)
            }
        },
        [activeIndex, input, inputSpeed, autoScroll, target],
    )

    const measureRow = useCallback(
        (focus: number): RowMetrics => {
            const openAspect = MathUtils.lerp(tileAspect, ROW_TILE_ASPECT, focus)
            const openWidth = tileHeight * openAspect
            const fitScale = surfaceScale(bounds.current, autoScale) * scale

            if (focus === 0 || !(camera instanceof THREE.PerspectiveCamera)) {
                return { openAspect, openWidth, fitScale, flatSpacing: bandSpacing }
            }

            return {
                openAspect,
                openWidth,
                fitScale: MathUtils.lerp(fitScale, rowZoom(camera, focusScale, tileHeight), focus),
                flatSpacing: MathUtils.lerp(bandSpacing, openWidth * (1 + FOCUS_GAP_RATIO), focus),
            }
        },
        [tileAspect, tileHeight, autoScale, scale, camera, focusScale, bandSpacing, bounds],
    )

    const layoutTiles = useCallback(
        (row: RowMetrics, frame: FrameState) => {
            const { position, tension, presence, spiral, focus, time } = frame
            const growth = 1 + tension * scrollGrowth
            const pitch = angleStep * (1 + tension * scrollSpread)
            const grownRadius = radius * growth

            meshRefs.current.forEach((mesh, index) => {
                if (!mesh) return

                const offset = wrap(0, tileCount, index - position + half) - half
                const angle = offset * pitch
                const distance = Math.abs(offset)

                const edgeFade = 1 - MathUtils.smoothstep(distance, half * 0.46, half * 0.75)
                const rowFade = 1 - MathUtils.smoothstep(distance, 1, 2)
                const opacity = MathUtils.lerp(edgeFade, rowFade, focus) * presence

                mesh.position.set(
                    MathUtils.lerp(offset * row.flatSpacing, Math.sin(angle) * grownRadius, spiral),
                    offset * verticalSpacing * growth * spiral,
                    (Math.cos(angle) * grownRadius - radius) * spiral,
                )
                mesh.rotation.y = angle * tileRotation * spiral
                mesh.scale.x = row.openAspect / tileAspect
                mesh.renderOrder = Math.round(mesh.position.z * 100)
                mesh.visible = opacity > 0.002
                mesh.material.depthWrite = opacity > 0.99

                coverCrop(mesh.material, tiles[index].imageAspect, row.openAspect)
                mesh.material.uTileSize.set(row.openWidth, tileHeight)
                mesh.material.uOpacity = opacity
                mesh.material.uCurve = curve * spiral
                mesh.material.uWave = tension * wave * (1 - focus)
                mesh.material.uTime = time
            })
        },
        [
            tiles,
            tileCount,
            half,
            angleStep,
            radius,
            scrollGrowth,
            scrollSpread,
            verticalSpacing,
            tileRotation,
            tileAspect,
            tileHeight,
            curve,
            wave,
        ],
    )

    useFrame((state, delta) => {
        const fit = fitRef.current
        if (!fit) return

        const step = Math.min(delta, 0.05)
        const { presence, spiral, focus } = progress.current

        advanceScroll(step)

        const row = measureRow(focus)
        fit.scale.setScalar(row.fitScale)
        blurRef.current = lensBlur * (1 - focus + Math.sin(focus * Math.PI) * FOCUS_LENS_FLARE)

        const position = MathUtils.lerp(scroll.get(), target.get(), focus)
        const lag = Math.min(Math.abs(target.get() - position) / MAX_LAG, 1)
        tensionRef.current = MathUtils.damp(tensionRef.current, lag, 8, step)

        layoutTiles(row, {
            position,
            tension: tensionRef.current,
            presence,
            spiral,
            focus,
            time: state.clock.elapsedTime,
        })
    })

    return (
        <PostProcessing bounds={bounds} strength={blurRef}>
            <group ref={fitRef}>
                <group
                    ref={groupRef}
                    onPointerMissed={() => select(null)}
                    onPointerOver={() => {
                        pointer.current.hovering = true
                        applyCursor()
                    }}
                    onPointerOut={() => {
                        pointer.current.hovering = false
                        applyCursor()
                    }}
                >
                    {tiles.map((tile, index) => (
                        <mesh
                            key={index}
                            ref={(mesh) => {
                                meshRefs.current[index] = mesh as PlaneMesh | null
                            }}
                            onClick={(event) => {
                                event.stopPropagation()
                                select(index)
                            }}
                        >
                            <planeGeometry args={[width, tileHeight, 24, 2]} />
                            <spiralTileMaterial
                                key={SpiralTileMaterial.key}
                                uMap={tile.texture}
                                uTileSize={new THREE.Vector2(width, tileHeight)}
                                uUvScale={tile.uvScale}
                                uUvOffset={tile.uvOffset}
                                uRadius={cornerRadius}
                                uOpacity={0}
                                side={THREE.DoubleSide}
                                transparent
                                depthWrite={false}
                            />
                        </mesh>
                    ))}
                </group>
            </group>
        </PostProcessing>
    )
}

export function SpiralGallery({
    items,
    className,
    mode,
    priority,
    zIndex,
    transparent,
    autoReflow,
    ...rest
}: SpiralGalleryProps) {
    const surface = useRef<ComponentRef<"div">>(null)
    const sceneProps = { ...DEFAULT_PROPS, ...rest }
    const [activeIndex, setActiveIndex] = useState<number | null>(null)
    let touch = ""

    if (sceneProps.input === "wheel" || sceneProps.drag) {
        touch = "touch-none"
    }

    return (
        <div ref={surface} className={`${touch} select-none ${className ?? ""}`}>
            <ul className="sr-only">
                {items.map((image, index) => (
                    <li key={image.src}>
                        <button
                            type="button"
                            aria-current={activeIndex === index}
                            onClick={() => setActiveIndex(index)}
                        >
                            <img src={image.src} alt={image.alt} />
                        </button>
                    </li>
                ))}
            </ul>

            {items.length > 0 && (
                <WebglScene
                    track={surface}
                    mode={mode}
                    priority={priority}
                    zIndex={zIndex}
                    transparent={transparent}
                    autoReflow={autoReflow}
                >
                    <SpiralScene
                        {...sceneProps}
                        surface={surface}
                        sources={items.map((image) => image.src)}
                        activeIndex={activeIndex}
                        onSelect={setActiveIndex}
                    />
                </WebglScene>
            )}
        </div>
    )
}
webgl-scene.tsx
"use client"

import { shaderMaterial, useFBO } from "@react-three/drei"
import { createPortal, extend, type ThreeElement, useFrame, useThree } from "@react-three/fiber"
import { type ReactNode, type RefObject, useLayoutEffect, useMemo, useRef } from "react"
import { type Mesh, PerspectiveCamera, Scene, Texture } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"

const DisplayMaterial = shaderMaterial(
    { uMap: new Texture() },
    /* glsl */ `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    /* glsl */ `
        uniform sampler2D uMap;
        varying vec2 vUv;
        void main() {
            gl_FragColor = texture2D(uMap, vUv);
        }
    `,
)

extend({ DisplayMaterial })

declare module "@react-three/fiber" {
    interface ThreeElements {
        displayMaterial: ThreeElement<typeof DisplayMaterial>
    }
}

export type WebglSceneProps = {
    track: RefObject<HTMLElement | null>
    children: ReactNode
    camera?: PerspectiveCamera
    /**
     * - texture: children render into an FBO each frame: Global post-processing will work on it.
     * - scissor: a scissored pass painted on top of the composed frame. lighter, but excluded from global post-processing.
     */
    mode?: "texture" | "scissor"
    priority?: number
    zIndex?: number
    transparent?: boolean
    /**
     * Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
     * Costs one layout read per frame, so only enable it when needed.
     */
    autoReflow?: boolean
}

function WebglScenePortal({
    track,
    children,
    camera: propCamera,
    mode = "scissor",
    priority,
    zIndex = 0,
    transparent = true,
    autoReflow = false,
}: WebglSceneProps) {
    const defaultCamera = useMemo(() => {
        const cam = new PerspectiveCamera(75, 1, 0.1, 1000)
        cam.position.z = 5
        return cam
    }, [])

    const scene = useMemo(() => new Scene(), [])
    const camera = propCamera ?? defaultCamera
    const bounds = useRef({
        x: 0,
        y: 0,
        width: 0,
        height: 0,
    })

    const gl = useThree((s) => s.gl)
    const size = useThree((s) => s.size)
    const viewport = useThree((s) => s.viewport)
    const displayMesh = useRef<Mesh>(null)
    const fbo = useFBO(1, 1, { samples: 4 })

    useLayoutEffect(() => {
        fbo.texture.colorSpace = gl.outputColorSpace
    }, [fbo, gl])

    useLayoutEffect(() => {
        const target = track.current
        if (!target) return

        const measure = () => {
            const rect = target.getBoundingClientRect()
            bounds.current.x = rect.left + window.scrollX
            bounds.current.y = rect.top + window.scrollY
            bounds.current.width = rect.width
            bounds.current.height = rect.height
        }

        measure()
        const resizeObserver = new ResizeObserver(measure)
        resizeObserver.observe(target)
        resizeObserver.observe(document.body)
        return () => resizeObserver.disconnect()
    }, [track])

    const renderPriority = priority ?? (mode === "texture" ? 0 : 2)

    useFrame(() => {
        const transitioning = document.documentElement.hasAttribute("data-atelier-transitioning")

        let left: number
        let top: number
        let width: number
        let height: number

        if ((autoReflow || transitioning) && track.current) {
            const rect = track.current.getBoundingClientRect()
            left = rect.left
            top = rect.top
            width = rect.width
            height = rect.height
        } else {
            const b = bounds.current
            left = b.x - window.scrollX
            top = b.y - window.scrollY
            width = b.width
            height = b.height
        }

        if (width === 0 || height === 0) return

        const aspect = width / height
        if (camera.aspect !== aspect) {
            camera.aspect = aspect
            camera.updateProjectionMatrix()
        }

        if (mode === "scissor") {
            const canvasHeight = gl.domElement.clientHeight
            const canvasWidth = gl.domElement.clientWidth

            const previousAutoClear = gl.autoClear
            gl.autoClear = false
            gl.setViewport(left, canvasHeight - (top + height), width, height)
            gl.setScissor(left, canvasHeight - (top + height), width, height)
            gl.setScissorTest(true)
            gl.clear()
            gl.render(scene, camera)
            gl.setScissorTest(false)
            gl.setViewport(0, 0, canvasWidth, canvasHeight)
            gl.setScissor(0, 0, canvasWidth, canvasHeight)
            gl.autoClear = previousAutoClear
            return
        }

        const pixelRatio = gl.getPixelRatio()
        const fboWidth = Math.max(1, Math.ceil(width * pixelRatio))
        const fboHeight = Math.max(1, Math.ceil(height * pixelRatio))

        if (fbo.width !== fboWidth || fbo.height !== fboHeight) {
            fbo.setSize(fboWidth, fboHeight)
        }

        const previousClearAlpha = gl.getClearAlpha()
        const previousAutoClear = gl.autoClear

        gl.autoClear = true
        gl.setRenderTarget(fbo)
        gl.setClearAlpha(transparent ? 0 : 1)
        gl.clear()
        gl.render(scene, camera)
        gl.setRenderTarget(null)
        gl.setClearAlpha(previousClearAlpha)
        gl.autoClear = previousAutoClear

        const mesh = displayMesh.current

        if (mesh) {
            const pxToWorld = viewport.height / size.height
            mesh.position.x = (left + width / 2 - size.width / 2) * pxToWorld
            mesh.position.y = -(top + height / 2 - size.height / 2) * pxToWorld
            mesh.scale.x = width * pxToWorld
            mesh.scale.y = height * pxToWorld
        }
    }, renderPriority)

    const portal = createPortal(children, scene, {
        camera,
        events: {
            compute: (event, state) => {
                const rect = track.current?.getBoundingClientRect()
                if (!rect) return
                state.pointer.set(
                    ((event.clientX - rect.left) / rect.width) * 2 - 1,
                    -(((event.clientY - rect.top) / rect.height) * 2 - 1),
                )
                state.raycaster.setFromCamera(state.pointer, camera)
            },
        },
    })

    return (
        <>
            {portal}
            {mode === "texture" && (
                <mesh ref={displayMesh} renderOrder={zIndex}>
                    <planeGeometry args={[1, 1]} />
                    <displayMaterial
                        key={DisplayMaterial.key}
                        uMap={fbo.texture}
                        transparent
                        premultipliedAlpha
                        depthTest={false}
                        depthWrite={false}
                    />
                </mesh>
            )}
        </>
    )
}

export function WebglScene(props: WebglSceneProps) {
    return (
        <webglTeleport.In>
            <WebglScenePortal {...props} />
        </webglTeleport.In>
    )
}
webgl-provider.tsx
"use client"

import { advance, Canvas, type CanvasProps, useStore, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ComponentRef, type ReactNode, useEffect, useRef, useState } from "react"
import type { Camera, Scene } from "three"
import { effectTeleport, WebglPortal } from "../webgl-portal/webgl-portal"

type WebglProviderProps = Omit<CanvasProps, "children" | "eventSource"> & {
    children: ReactNode
    className?: string
    contained?: boolean
}

type WebglReadyOptions = {
    scene?: Scene
    camera?: Camera
    enabled?: boolean
    onReady?: () => void
}

export function useWebglReady({ scene, camera, enabled = true, onReady }: WebglReadyOptions = {}) {
    const [ready, setReady] = useState(false)
    const gl = useThree((state) => state.gl)
    const defaultScene = useThree((state) => state.scene)
    const defaultCamera = useThree((state) => state.camera)
    const onReadyRef = useRef(onReady)
    onReadyRef.current = onReady

    const targetScene = scene ?? defaultScene
    const targetCamera = camera ?? defaultCamera

    useEffect(() => {
        if (!enabled) return
        let active = true

        gl.compileAsync(targetScene, targetCamera).then(() => {
            if (!active) return
            requestAnimationFrame(() => {
                if (!active) return
                setReady(true)
                onReadyRef.current?.()
            })
        })

        return () => {
            active = false
        }
    }, [gl, targetScene, targetCamera, enabled])

    return ready
}

// Renders in Motion's `postRender` phase, after Lenis and Motion have
// updated. One shared driver serves every mounted provider.
type CanvasStore = ReturnType<typeof useStore>
const canvasStores = new Set<CanvasStore>()
let clockStart: number | null = null

function tick(data: FrameData) {
    if (clockStart === null) clockStart = data.timestamp

    // frameloop="never" expects the elapsed clock time in seconds.
    const elapsed = (data.timestamp - clockStart) / 1000

    let runGlobalEffects = true
    for (const store of canvasStores) {
        const state = store.getState()
        if (state.internal.active) {
            advance(elapsed, runGlobalEffects, state)
            runGlobalEffects = false
        }
    }
}

function MotionFrameloop() {
    const store = useStore()

    useEffect(() => {
        canvasStores.add(store)
        if (canvasStores.size === 1) frame.postRender(tick, true)
        return () => {
            canvasStores.delete(store)
            if (canvasStores.size === 0) cancelFrame(tick)
        }
    }, [store])

    return null
}

function Effects() {
    const effects = effectTeleport.useItems()
    const gl = useThree((state) => state.gl)
    const mounted = effects.length > 0

    // EffectComposer sets `renderer.autoClear = false` and never restores it;
    // without this the canvas keeps its last frame once the composer unmounts.
    useEffect(() => {
        if (!mounted) return
        return () => {
            gl.autoClear = true
        }
    }, [mounted, gl])

    if (!mounted) return null

    return (
        <EffectComposer key={effects.length}>
            <effectTeleport.Out />
        </EffectComposer>
    )
}

export function WebglProvider({
    children,
    className,
    style,
    contained = false,
    ...canvasProps
}: WebglProviderProps) {
    const [eventSource, setEventSource] = useState<ComponentRef<"div"> | null>(null)

    return (
        <div
            ref={setEventSource}
            data-atelier-webgl=""
            className={className}
            style={contained ? { position: "relative" } : { display: "contents" }}
        >
            <Canvas
                eventPrefix="client"
                dpr={[1, 2]}
                {...canvasProps}
                frameloop="never"
                eventSource={eventSource ?? undefined}
                style={{
                    position: contained ? "absolute" : "fixed",
                    inset: 0,
                    pointerEvents: "none",
                    ...style,
                }}
            >
                <MotionFrameloop />
                <WebglPortal />
                <Effects />
            </Canvas>

            {children}
        </div>
    )
}
webgl-portal.tsx
"use client"

import {
    type ReactNode,
    Suspense,
    useEffect,
    useId,
    useLayoutEffect,
    useSyncExternalStore,
} from "react"

const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect

// Minimal teleport: <In> registers children in an external store,
// <Out> renders them — bridges across the Canvas React root the same
function WebglTeleport() {
    const items = new Map<string, ReactNode>()
    const listeners = new Set<() => void>()
    let snapshot: [string, ReactNode][] = []

    const emit = () => {
        snapshot = Array.from(items.entries())
        for (const listener of listeners) {
            listener()
        }
    }

    const subscribe = (listener: () => void) => {
        listeners.add(listener)
        return () => {
            listeners.delete(listener)
        }
    }
    const getSnapshot = () => snapshot

    function useItems() {
        return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
    }

    return {
        In({ children }: { children: ReactNode }) {
            const id = useId()

            useIsoLayoutEffect(() => {
                items.set(id, children)
                emit()
                return () => {
                    items.delete(id)
                    emit()
                }
            }, [id, children])
            return null
        },
        useItems,
        Out() {
            const list = useItems()
            return (
                <>
                    {list.map(([id, node]) => (
                        <Suspense key={id} fallback={null}>
                            {node}
                        </Suspense>
                    ))}
                </>
            )
        },
    }
}

const webglTeleport = WebglTeleport()
const effectTeleport = WebglTeleport()

export function WebglPortal() {
    return <webglTeleport.Out />
}

export { effectTeleport, webglTeleport }

All WebGL components render through one shared canvas. Add the WebglProvider once at the root of your app:

Root layout
import { WebglProvider } from "@/components/webgl-provider/webgl-provider";

export default function RootLayout({ children }) {
  return <WebglProvider>{children}</WebglProvider>;
}

Then use it anywhere in your app:

const ITEMS = Array.from({ length: 20 }, (_, i) => ({
    src: `https://picsum.photos/seed/atelier-${i + 1}/1200/800`,
    alt: "",
}))

<SpiralGallery items={ITEMS} className="fixed inset-0" />

You can pass fewer images than tileCount. The list repeats until every tile on the tube has one.

Scroll input

By default the carousel moves with the wheel. If you want it to use the real page scroll, set input="scroll":

<SpiralGallery items={ITEMS} input="scroll" className="fixed inset-0" />

Post-processing

The gallery renders through a WebGL Scene layered above other content. Set mode="texture" to render it into post-processing instead, so effects like FluidDistortion apply to the spiral:

<FluidDistortion />

<SpiralGallery items={ITEMS} mode="texture" className="fixed inset-0" />

NameTypeDefaultDescription
itemsSpiralGalleryItem[]-The images to show, each one as { src, alt }. They repeat to fill tileCount. Required.
radiusnumber5Radius of the tube the images travel around, in world units.
tileHeightnumber2.45Height of each image, in world units.
tileAspectnumber1.5Width-to-height ratio of each image. Images are cropped to fit.
tileCountnumber16Number of images on screen at once. Items repeat if there are fewer.
verticalSpacingnumber0.95Vertical distance between one image and the next.
turnAnglenumber46Angle between one image and the next around the tube, in degrees.
tileRotationnumber1How much each image rotates on its own vertical axis as it orbits. 1 keeps images flat against the tube, 0 faces them all the same way.
cornerRadiusnumber0.05Corner rounding of each image, in world units.
curvenumber0.1How much each image bends inward across its width.
autoScrollnumber0.1Speed the carousel scrolls on its own, with no input.
easingnumber0.1How long the scroll takes to settle, in seconds. Higher is slower.
input"wheel" | "scroll" | "none""wheel"Where scrolling comes from. "wheel" captures the wheel over the gallery, "scroll" follows the page scroll, "none" leaves only dragging.
inputSpeednumber1.1Multiplier on wheel and drag distance. Higher moves further per gesture.
dragbooleantrueAllow dragging the carousel with the pointer.
scrollSpreadnumber0.5Extra angle added between images while scrolling. 0 keeps the spacing fixed.
scrollGrowthnumber0.65How much the radius and vertical spacing grow while scrolling.
lensBlurnumber0.4Strength of the lens pass: edge blur, RGB split and the fisheye bulge that swells while an image opens. 0 disables it; higher values blur more toward the edges.
autoScalebooleantrueShrink the spiral on narrow viewports so it stays inside the container.
scalenumber1.3Overall size of the spiral, applied on top of autoScale.
revealbooleantruePlay the mount animation, where the images start as a flat band and wrap onto themselves into the tube, turning once around.
revealDurationnumber2How long the mount animation takes, in seconds.
focusDurationnumber2.1How long the click-to-focus unwinding takes, in seconds. Sliding between two open images takes 40% of it.
focusScalenumber0.7Share of the container height the open image covers, from 0 to 1. Lower it for more room around the row.
classNamestring-Classes for the interaction surface the gallery renders into. Sizes the gallery, any container size works.

It also forwards WebGL Scene props: mode, priority, zIndex, transparent, and autoReflow.


React Three Fiber
React renderer for Three.js.

Motion
React animation library.

WebGL Scene (Atelier)
A viewport with its own scene and camera on the shared canvas.

WebGL Provider (Atelier)
A single shared WebGL canvas for the whole app.

Star on githubBuy me a coffeellms.txt