Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (32)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Sphere Gallery
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered 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. Sphere Gallery

Sphere Gallery

A WebGL gallery that maps images onto a sphere. Drag to look around, click an image to zoom in.

React Three Fiber
Drei
Tailwind CSS
https://atelier-ui.com/sphere-gallery

Settings

rows
7
columns
12
latitude-range
85
gap
0.01
padding
0.03
corner-radius
0.02
tile-color
#F8F8F8
sphere-color
#ffffff
show-tile-color
lens-blur
0.40
fov
70
reveal-duration
2.0
focus-duration
1.0
focus-scale
1.7
mouse-parallax
0.20
See the documentation below for more options.

Install

npx atelier-ui add sphere-gallery
npm install three @react-three/fiber @react-three/drei motion
sphere-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 } 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 { useWebglReady } from "../webgl-provider/webgl-provider"
import { WebglScene, type WebglSceneProps } from "../webgl-scene/webgl-scene"

const TAU = Math.PI * 2
const MAX_TILT = Math.PI / 4
const SCENE_DISTANCE = 0.1 as const
const INITIAL_DISTANCE = 2 as const
const PEEK_DEPTH = 1.5 as const
const PEEK_MARGIN = 0.15 as const
const FOCUS_EASING = [0.7, 0.03, 0.26, 0.99] as Easing
const REVEAL_EASING = [0.4, 0.2, 0.15, 1] as Easing

const DEFAULT_PROPS = {
    rows: 7,
    columns: 12,
    latitudeRange: 85,
    gap: 0.01,
    padding: 0.03,
    cornerRadius: 0.02,
    lensBlur: 0.4,
    fov: 70,
    tileColor: "#F8F8F8" as string | null,
    sphereColor: "#ffffff" as string,
    reveal: true,
    revealDuration: 2,
    focusDuration: 1,
    focusScale: 1.7,
    mouseParallax: 0.2,
}

type LayoutTile = {
    position: THREE.Vector3
    quaternion: THREE.Quaternion
    longitude: number
    latitude: number
    width: number
    height: number
    span: THREE.Vector2
}

type TileProps = {
    texture: THREE.Texture
    tile: LayoutTile
    index: number
    activeTile: number | null
    ready: boolean
    interactive: boolean
    setPointer: (on: boolean) => void
    onSelect: () => void
} & Pick<
    typeof DEFAULT_PROPS,
    | "gap"
    | "padding"
    | "cornerRadius"
    | "tileColor"
    | "reveal"
    | "revealDuration"
    | "focusDuration"
    | "focusScale"
>

type PeekSlot = {
    index: number
    texture: THREE.Texture
    position: THREE.Vector3
    quaternion: THREE.Quaternion
    width: number
    height: number
}

type PeekTileProps = {
    slot: PeekSlot | null
    setPointer: (on: boolean) => void
    onNavigate: (index: number) => void
    focusDuration: number
}

type SphereSceneProps = {
    sources: string[]
    surface: RefObject<HTMLElement | null>
    activeTile: number | null
    onSelect: (index: number) => void
    onNavigate: (index: number) => void
    onDismiss: () => void
    onReady?: () => void
} & Omit<typeof DEFAULT_PROPS, "lensBlur">

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

export type SphereGalleryProps = {
    items: SphereGalleryItem[]
    className?: string
    onActiveChange?: (index: number | null) => void
    onReady?: () => void
} & Partial<typeof DEFAULT_PROPS> &
    Pick<WebglSceneProps, "mode" | "priority" | "zIndex" | "transparent">

declare module "@react-three/fiber" {
    interface ThreeElements {
        tileMaterial: ThreeElement<typeof TileMaterial>
        lensBlurMaterial: ThreeElement<typeof LensBlurMaterial>
    }
}

/*
 * Shader material for each tile.
 * Draws the image, the rounded mask and the dissolve effect.
 */
const TileMaterial = shaderMaterial(
    {
        uMap: new THREE.Texture(),
        uFocus: 0,
        uLatitude: 0,
        uAngularSpan: new THREE.Vector2(1, 1),
        uImageAspect: 1,
        uTileSize: new THREE.Vector2(1, 1),
        uGap: 0,
        uPadding: 0.1,
        uRadius: 0.1,
        uBackground: new THREE.Color("#d4d4d4"),
        uBackgroundAlpha: 1,
        uDissolve: 0,
        uSeed: 0,
        uOpacity: 1,
        uReveal: 1,
    },
    /* glsl */ `
        uniform float uFocus;
        uniform float uLatitude;
        uniform vec2 uAngularSpan;
        varying vec2 vUv;

        void main() {
            vUv = uv;

            float longitude = (uv.x - 0.5) * uAngularSpan.x;
            float latitude = uLatitude + (uv.y - 0.5) * uAngularSpan.y;

            vec3 spherePosition = vec3(
                cos(latitude) * sin(longitude),
                sin(latitude) * cos(uLatitude) - cos(latitude) * sin(uLatitude) * cos(longitude),
                1.0 - cos(latitude) * cos(uLatitude) * cos(longitude) - sin(latitude) * sin(uLatitude)
            );

            vec3 morphedPosition = mix(spherePosition, position, uFocus);

            gl_Position = projectionMatrix * modelViewMatrix * vec4(morphedPosition, 1.0);
        }
    `,
    /* glsl */ `
        uniform sampler2D uMap;
        uniform float uImageAspect;
        uniform vec2 uTileSize;
        uniform float uGap;
        uniform float uPadding;
        uniform float uRadius;
        uniform vec3 uBackground;
        uniform float uBackgroundAlpha;
        uniform float uDissolve;
        uniform float uSeed;
        uniform float uOpacity;
        uniform float uReveal;

        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;
        }

        float hash(vec2 point) {
            return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
        }

        float valueNoise(vec2 point) {
            vec2 cell = floor(point);
            vec2 offset = fract(point);
            float bottomLeft = hash(cell);
            float bottomRight = hash(cell + vec2(1.0, 0.0));
            float topLeft = hash(cell + vec2(0.0, 1.0));
            float topRight = hash(cell + vec2(1.0, 1.0));
            vec2 smoothed = offset * offset * (3.0 - 2.0 * offset);
            return mix(bottomLeft, bottomRight, smoothed.x)
                + (topLeft - bottomLeft) * smoothed.y * (1.0 - smoothed.x)
                + (topRight - bottomRight) * smoothed.x * smoothed.y;
        }

        float fbm(vec2 point) {
            float value = 0.0;
            float amplitude = 0.5;
            for (int octave = 0; octave < 3; octave++) {
                value += amplitude * valueNoise(point);
                point *= 2.0;
                amplitude *= 0.5;
            }
            return value;
        }

        void main() {
            vec2 point = (vUv - 0.5) * uTileSize;
            vec2 halfTile = uTileSize * 0.5 - uGap;
            vec2 content = halfTile - uPadding;

            float imageHalfHeight = min(content.x / uImageAspect, content.y);
            vec2 imageHalfSize = vec2(imageHalfHeight * uImageAspect, imageHalfHeight);
            vec2 imageUv = point / (imageHalfSize * 2.0) + 0.5;

            bool inside = all(greaterThanEqual(imageUv, vec2(0.0))) &&
                all(lessThanEqual(imageUv, vec2(1.0)));

            vec3 color = inside ? texture2D(uMap, imageUv).rgb : uBackground;

            float boxDistance = sdRoundBox(point, halfTile, uRadius);
            float boxAntialias = fwidth(boxDistance);
            float alpha = smoothstep(boxAntialias, -boxAntialias, boxDistance);

            alpha *= inside ? 1.0 : uBackgroundAlpha;

            if (uDissolve > 0.0) {
                float threshold = mix(-0.1, 0.95, uDissolve);
                float noise = fbm(vUv * 3.0 + uSeed * 7.13);
                float edgeDistance = noise - threshold;
                float edgeAntialias = fwidth(edgeDistance);
                alpha *= smoothstep(-edgeAntialias, edgeAntialias, edgeDistance);

                float rim = 1.0 - smoothstep(0.0, 0.1, edgeDistance);
                color += rim * 0.4;
            }

            gl_FragColor = vec4(color, alpha * uOpacity * uReveal);
        }
    `,
)

/*
 * Full-screen lens-blur shader.
 * Used by the post-processing pass over the rendered scene.
 */
const LensBlurMaterial = shaderMaterial(
    {
        uScene: new THREE.Texture(),
        uStrength: 0.16,
        uRadius: 0.3,
        uSmoothness: 0.5,
        uDispersion: 0.35,
        uMotion: 0,
        uMotionStrength: 0.4,
    },
    /* 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;
        uniform float uMotion;
        uniform float uMotionStrength;

        varying vec2 vUv;

        const int SAMPLES = 24;

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

            float radius = uRadius * (1.0 - 0.7 * uMotion);
            float mask = smoothstep(radius, radius + uSmoothness, distanceFromCenter);
            float amount = mask * mask * uStrength + mask * uMotion * uMotionStrength;

            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({ TileMaterial, LensBlurMaterial })

type PostProcessingProps = {
    surface: RefObject<HTMLElement | null>
    children: ReactNode
    strength: number
    active: boolean
}

/*
 * Renders the scene into an off-screen buffer.
 * Applies the lens-blur pass and tracks camera motion.
 */
function PostProcessing({ surface, children, strength, active }: PostProcessingProps) {
    const gl = useThree((state) => state.gl)
    const camera = useThree((state) => state.camera)
    const contentScene = useMemo(() => new THREE.Scene(), [])
    const screenRef =
        useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof LensBlurMaterial>>>(null)
    const bounds = useRef({ width: 0, height: 0 })
    const motion = useRef({ previousZ: null as number | null, strength: 0 })
    const fbo = useFBO(1, 1, { samples: 4 })

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

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

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

    useFrame((_, delta) => {
        const screen = screenRef.current
        const { width, height } = bounds.current
        if (!screen) return

        screen.material.uStrength = MathUtils.damp(
            screen.material.uStrength,
            active ? 0 : strength,
            5,
            delta,
        )

        const cameraZ = camera.position.z
        const previousZ = motion.current.previousZ
        motion.current.previousZ = cameraZ

        if (delta > 0 && previousZ !== null) {
            const speed = Math.abs(cameraZ - previousZ) / delta
            const target = Math.min(speed * 0.1, 1)
            motion.current.strength = MathUtils.damp(motion.current.strength, target, 14, delta)
            screen.material.uMotion = motion.current.strength
        }

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

        const aspect = width / height

        if (camera instanceof THREE.PerspectiveCamera && camera.aspect !== aspect) {
            camera.aspect = aspect
            camera.updateProjectionMatrix()
        }

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

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

            <mesh ref={screenRef} frustumCulled={false}>
                <planeGeometry args={[2, 2]} />
                <lensBlurMaterial
                    key={LensBlurMaterial.key}
                    uScene={fbo.texture}
                    transparent
                    premultipliedAlpha
                    depthTest={false}
                    depthWrite={false}
                />
            </mesh>
        </>
    )
}

/*
 * One image tile placed on the sphere.
 * Handles hover, focus and dissolve transitions.
 */
function Tile({
    texture,
    tile,
    gap,
    padding,
    cornerRadius,
    tileColor,
    index,
    activeTile,
    ready,
    interactive,
    setPointer,
    onSelect,
    reveal,
    revealDuration,
    focusDuration,
    focusScale,
}: TileProps) {
    const [hovered, setHovered] = useState(false)
    const meshRef = useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof TileMaterial>>>(null)
    const image = texture.image as HTMLImageElement
    const focused = activeTile === index
    const dissolving = activeTile !== null && !focused

    useEffect(() => {
        function tileFocusAnimation() {
            const mesh = meshRef.current
            const material = mesh?.material
            if (!mesh || !material) return

            const scale = focused ? focusScale : 1

            const controls = animate([
                [
                    material,
                    { uFocus: focused ? 1 : 0 },
                    { duration: focusDuration, ease: FOCUS_EASING },
                ],
                [
                    material,
                    { uDissolve: dissolving ? 1 : 0 },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
                [
                    material,
                    { uGap: focused ? 0 : gap },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
                [
                    material,
                    { uPadding: focused ? 0 : padding },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
                [
                    material,
                    { uRadius: focused ? 0 : cornerRadius },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
                [
                    material,
                    { uBackgroundAlpha: focused ? 0 : 1 },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
                [
                    mesh.scale,
                    { x: scale, y: scale, z: scale },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
            ])
            return () => controls.stop()
        }

        return tileFocusAnimation()
    }, [cornerRadius, dissolving, focused, gap, padding, focusDuration, focusScale])

    useEffect(() => {
        if (!reveal || !ready) return
        function tileRevealAnimation() {
            const material = meshRef.current?.material
            if (!material) return
            const controls = animate(
                material,
                { uReveal: 1, uDissolve: [0.5, 0] },
                { duration: revealDuration * 1.2, ease: REVEAL_EASING },
            )
            return () => controls.stop()
        }
        return tileRevealAnimation()
    }, [reveal, ready, revealDuration])

    useEffect(() => {
        function tileHoverAnimation() {
            const material = meshRef.current?.material
            if (!material) return
            const controls = animate(
                material,
                { uOpacity: hovered && !focused ? 0.7 : 1 },
                { duration: hovered && !focused ? 0.2 : 0.3 },
            )
            return () => controls.stop()
        }

        return tileHoverAnimation()
    }, [hovered, focused])

    return (
        <mesh
            ref={meshRef}
            position={tile.position}
            quaternion={tile.quaternion}
            raycast={
                interactive && (focused || activeTile === null)
                    ? THREE.Mesh.prototype.raycast
                    : () => null
            }
            onClick={(event) => {
                event.stopPropagation()
                onSelect()
                setPointer(false)
            }}
            onPointerOver={(event) => {
                event.stopPropagation()
                setHovered(true)
                setPointer(!focused)
            }}
            onPointerOut={() => {
                setHovered(false)
                setPointer(false)
            }}
        >
            <planeGeometry args={[tile.width, tile.height, 24, 24]} />
            <tileMaterial
                key={TileMaterial.key}
                uMap={texture}
                uDissolve={0}
                uReveal={0}
                uLatitude={tile.latitude}
                uAngularSpan={tile.span}
                uImageAspect={image.width / image.height}
                uTileSize={new THREE.Vector2(tile.width, tile.height)}
                uGap={gap}
                uPadding={padding}
                uRadius={cornerRadius}
                uBackground={new THREE.Color(tileColor ?? "#000000")}
                uBackgroundAlpha={tileColor ? 1 : 0}
                uSeed={index}
                side={THREE.DoubleSide}
                transparent
                depthWrite={false}
            />
        </mesh>
    )
}

/*
 * Peek tile preview shown beside once a tile is focused.
 * Lets the user navigate to the previous or next image.
 */
function PeekTile({ slot, setPointer, onNavigate, focusDuration }: PeekTileProps) {
    const [displayedTile, setDisplayedTile] = useState<PeekSlot | null>(null)
    const [hovered, setHovered] = useState(false)

    const meshRef = useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof TileMaterial>>>(null)
    const image = displayedTile?.texture.image as HTMLImageElement

    useEffect(() => {
        function peekTileDissolveAnimation() {
            const material = meshRef.current?.material

            if (displayedTile !== slot) {
                if (!material || displayedTile?.index === slot?.index) {
                    setDisplayedTile(slot)
                    return
                }

                const controls = animate(
                    material,
                    { uDissolve: 1 },
                    { duration: focusDuration * 0.8, ease: FOCUS_EASING },
                )
                controls.then(() => setDisplayedTile(slot))
                return () => {
                    controls.stop()
                }
            }

            if (!material) return

            const controls = animate(
                material,
                { uDissolve: 0 },
                { duration: focusDuration * 0.8, ease: FOCUS_EASING },
            )
            return () => controls.stop()
        }
        return peekTileDissolveAnimation()
    }, [slot, displayedTile, focusDuration])

    useEffect(() => {
        function tileHoverAnimation() {
            const material = meshRef.current?.material
            if (!material) return
            const controls = animate(
                material,
                { uOpacity: hovered ? 0.7 : 1 },
                { duration: hovered ? 0.2 : 0.3 },
            )
            return () => controls.stop()
        }

        return tileHoverAnimation()
    }, [hovered])

    if (displayedTile === null) return null

    return (
        <mesh
            ref={meshRef}
            position={displayedTile.position}
            quaternion={displayedTile.quaternion}
            onClick={(event) => {
                event.stopPropagation()
                onNavigate(displayedTile.index)
            }}
            onPointerOver={(event) => {
                event.stopPropagation()
                setHovered(true)
                setPointer(true)
            }}
            onPointerOut={() => {
                setHovered(false)
                setPointer(false)
            }}
        >
            <planeGeometry args={[displayedTile.width, displayedTile.height]} />
            <tileMaterial
                uMap={displayedTile.texture}
                uFocus={1}
                uDissolve={1}
                uImageAspect={image.width / image.height}
                uTileSize={new THREE.Vector2(displayedTile.width, displayedTile.height)}
                uPadding={0}
                uRadius={0}
                uBackgroundAlpha={0}
                uSeed={displayedTile.index}
                side={THREE.DoubleSide}
                transparent
                depthWrite={false}
            />
        </mesh>
    )
}

/*
 * Builds the sphere tile layout.
 * Handles drag rotation, the reveal and focus animations.
 */
function SphereScene({
    sources,
    surface,
    activeTile,
    onSelect,
    onNavigate,
    onDismiss,
    onReady,
    rows,
    columns,
    latitudeRange,
    gap,
    padding,
    cornerRadius,
    tileColor,
    sphereColor,
    fov,
    reveal,
    revealDuration,
    focusDuration,
    focusScale,
    mouseParallax,
}: SphereSceneProps) {
    const [revealComplete, setRevealComplete] = useState(false)
    const interactive = !reveal || revealComplete
    const orientation = useRef({
        spin: 0,
        tilt: 0,
        targetSpin: 0,
        targetTilt: 0,
    }).current
    const pointerOffset = useRef(new THREE.Vector2())
    const parallax = useRef(new THREE.Vector2())
    const dragMoved = useRef(false)
    const dragging = useRef(false)
    const pointerOnTile = useRef(false)
    const animating = useRef(false)
    const groupRef = useRef<THREE.Group>(null)
    const sphereMaterialRef = useRef<THREE.MeshBasicMaterial>(null)
    const textures = useTexture(sources)
    const camera = useThree((state) => state.camera)
    const size = useThree((state) => state.size)
    const ready = useWebglReady({ onReady })

    const setPointer = useCallback(
        (on: boolean) => {
            pointerOnTile.current = on
            const element = surface.current
            if (!element || dragging.current) return
            element.style.cursor = on ? "pointer" : ""
        },
        [surface],
    )

    const focusDistanceFor = useCallback(
        (tile: LayoutTile) => {
            const tileSize = Math.max(tile.width, tile.height) * 1.9
            return tileSize / 2 / Math.tan(MathUtils.degToRad(fov) / 2)
        },
        [fov],
    )

    const tileLayout = useMemo(() => {
        const tiles: LayoutTile[] = []

        const orienter = new THREE.Object3D()
        const latRange = MathUtils.degToRad(latitudeRange)
        const latSpan = (latRange * 2) / rows

        for (let row = 0; row < rows; row++) {
            const latitude = -latRange + (row + 0.5) * latSpan
            const cosLat = Math.cos(latitude)

            const ringColumns = Math.max(1, Math.round(columns * cosLat))
            const lonSpan = TAU / ringColumns
            const span = new THREE.Vector2(lonSpan, latSpan)

            for (let col = 0; col < ringColumns; col++) {
                const longitude = (col + (row % 2) / 2) * lonSpan

                const position = new THREE.Vector3(
                    cosLat * Math.cos(longitude),
                    Math.sin(latitude),
                    cosLat * Math.sin(longitude),
                )

                orienter.position.copy(position)
                orienter.lookAt(0, 0, 0)

                tiles.push({
                    position,
                    quaternion: orienter.quaternion.clone(),
                    longitude,
                    latitude,
                    width: lonSpan * cosLat,
                    height: latSpan,
                    span,
                })
            }
        }

        return tiles
    }, [rows, columns, latitudeRange])

    const select = (index: number) => {
        if (dragMoved.current) return
        onSelect(index)
    }

    const navigate = (index: number) => {
        if (dragMoved.current) return
        onNavigate(index)
    }

    const peekSlots = useMemo(() => {
        if (activeTile === null) return { previous: null, next: null }

        const focused = tileLayout[activeTile]
        const sideAxis = new THREE.Vector3(1, 0, 0).applyQuaternion(focused.quaternion)
        const depthAxis = new THREE.Vector3(0, 0, 1).applyQuaternion(focused.quaternion)
        const total = tileLayout.length

        const peekDistance = focusDistanceFor(focused) + focused.width * PEEK_DEPTH
        const sideOffset =
            Math.tan(MathUtils.degToRad(fov) / 2) * peekDistance * (size.width / size.height) -
            focused.width / 2 -
            focused.width * PEEK_MARGIN

        const slot = (side: number): PeekSlot => {
            const index = (activeTile + side + total) % total
            const neighbor = tileLayout[index]
            return {
                index,
                texture: textures[index % textures.length],
                position: focused.position
                    .clone()
                    .addScaledVector(sideAxis, side * sideOffset)
                    .addScaledVector(depthAxis, -focused.width * PEEK_DEPTH),
                quaternion: focused.quaternion,
                width: neighbor.width,
                height: neighbor.height,
            }
        }

        return {
            previous: slot(-1),
            next: slot(1),
        }
    }, [activeTile, tileLayout, textures, focusDistanceFor, fov, size.width, size.height])

    useEffect(() => {
        const element = surface.current
        if (!element || !interactive) return

        const drag = { active: false, x: 0, y: 0, spin: 0, tilt: 0 }
        const DRAG_THRESHOLD = 6

        const beginDrag = (event: PointerEvent) => {
            drag.active = true
            drag.x = event.clientX
            drag.y = event.clientY
            drag.spin = orientation.targetSpin
            drag.tilt = orientation.targetTilt
            dragMoved.current = false
        }

        const rotate = (event: PointerEvent) => {
            const rect = element.getBoundingClientRect()

            pointerOffset.current.set(
                MathUtils.clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1),
                MathUtils.clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1),
            )

            if (!drag.active) return

            const deltaX = event.clientX - drag.x
            const deltaY = event.clientY - drag.y

            if (Math.abs(deltaX) > DRAG_THRESHOLD || Math.abs(deltaY) > DRAG_THRESHOLD) {
                dragMoved.current = true
            }

            if (activeTile !== null) return

            if (dragMoved.current && !dragging.current) {
                dragging.current = true
                element.style.cursor = "grabbing"
            }
            const width = element.offsetWidth

            orientation.targetSpin = drag.spin - (deltaX / width) * TAU
            orientation.targetTilt = MathUtils.clamp(
                drag.tilt - (deltaY / width) * Math.PI,
                -MAX_TILT,
                MAX_TILT,
            )
        }

        const endDrag = () => {
            drag.active = false
            dragging.current = false
            element.style.cursor = pointerOnTile.current ? "pointer" : ""
        }

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

        return () => {
            element.removeEventListener("pointerdown", beginDrag)
            window.removeEventListener("pointermove", rotate)
            window.removeEventListener("pointerup", endDrag)
            window.removeEventListener("pointercancel", endDrag)
        }
    }, [surface, activeTile, orientation, interactive])

    useEffect(() => {
        if (!reveal || !ready) return

        function revealSequenceAnimation() {
            camera.position.z = INITIAL_DISTANCE
            if (camera instanceof THREE.PerspectiveCamera && camera.fov !== fov) {
                camera.fov = fov

                camera.updateProjectionMatrix()
            }
            const group = groupRef.current

            if (!group) return

            const sphereMaterial = sphereMaterialRef.current
            if (!sphereMaterial) return

            const controls = animate([
                [
                    group.scale,
                    { x: 1, y: 1, z: 1 },
                    { duration: revealDuration, ease: REVEAL_EASING },
                ],
                [
                    sphereMaterial,
                    { opacity: 0.5 },
                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },
                ],
                [
                    group.rotation,
                    { x: 0 },
                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },
                ],
                [
                    group.rotation,
                    { y: 0 },
                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },
                ],
                [
                    camera.position,
                    { z: SCENE_DISTANCE },
                    {
                        duration: revealDuration * 0.7,
                        ease: FOCUS_EASING,
                        at: revealDuration * 0.7,
                    },
                ],
            ])

            controls.then(() => {
                pointerOffset.current.set(0, 0)
                setRevealComplete(true)
            })

            return () => {
                controls.stop()
            }
        }

        return revealSequenceAnimation()
    }, [camera, fov, reveal, ready, revealDuration])

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

            const focused = activeTile !== null ? tileLayout[activeTile] : null

            animating.current = true

            let spinAngle = group.rotation.y
            let tiltAngle = 0
            let zDistance = SCENE_DISTANCE * Math.max(1, size.height / size.width)
            let parallaxX = pointerOffset.current.x * mouseParallax
            let parallaxY = pointerOffset.current.y * mouseParallax

            if (focused) {
                const spin = focused.longitude + Math.PI / 2
                spinAngle = spin + Math.round((group.rotation.y - spin) / TAU) * TAU
                tiltAngle = -focused.latitude
                zDistance = focusDistanceFor(focused) - 1
                parallaxX = 0
                parallaxY = 0
            }

            const controls = animate([
                [
                    group.rotation,
                    {
                        x: [group.rotation.x, tiltAngle + parallaxY],
                        y: [group.rotation.y, spinAngle + parallaxX],
                    },
                    { duration: focusDuration, ease: FOCUS_EASING },
                ],
                [
                    camera.position,
                    { z: [camera.position.z, zDistance] },
                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },
                ],
            ])

            controls.then(() => {
                animating.current = false
                parallax.current.set(parallaxX, parallaxY)
                orientation.spin = group.rotation.y - parallax.current.x
                orientation.tilt = group.rotation.x - parallax.current.y
                orientation.targetSpin = orientation.spin
                orientation.targetTilt = tiltAngle
            })

            return () => controls.stop()
        }

        return focusOnTileSequence()
    }, [
        revealComplete,
        camera,
        focusDistanceFor,
        activeTile,
        tileLayout,
        orientation,
        size,
        focusDuration,
        mouseParallax,
    ])

    useFrame((_state, delta) => {
        const group = groupRef.current

        if (!group) return

        if (revealComplete && activeTile === null && !animating.current) {
            orientation.spin = MathUtils.damp(orientation.spin, orientation.targetSpin, 12, delta)
            orientation.tilt = MathUtils.damp(orientation.tilt, orientation.targetTilt, 12, delta)

            const offset = pointerOffset.current

            parallax.current.x = MathUtils.damp(
                parallax.current.x,
                offset.x * mouseParallax,
                4,
                delta,
            )
            parallax.current.y = MathUtils.damp(
                parallax.current.y,
                offset.y * mouseParallax,
                4,
                delta,
            )

            group.rotation.set(
                orientation.tilt + parallax.current.y,
                orientation.spin + parallax.current.x,
                0,
            )
        }
    })

    return (
        <group rotation-x={1} rotation-y={3} scale={0.5} ref={groupRef} onPointerMissed={onDismiss}>
            <mesh>
                <sphereGeometry args={[0.99, 64, 64]} />

                <meshBasicMaterial
                    ref={sphereMaterialRef}
                    transparent={true}
                    opacity={0}
                    color={sphereColor}
                />
            </mesh>

            {tileLayout.map((tile, i) => (
                <Tile
                    key={i}
                    texture={textures[i % textures.length]}
                    tile={tile}
                    gap={gap}
                    padding={padding}
                    cornerRadius={cornerRadius}
                    tileColor={tileColor}
                    index={i}
                    activeTile={activeTile}
                    ready={ready}
                    interactive={interactive}
                    setPointer={setPointer}
                    onSelect={() => select(i)}
                    reveal={reveal}
                    revealDuration={revealDuration}
                    focusDuration={focusDuration}
                    focusScale={focusScale}
                />
            ))}

            <PeekTile
                slot={peekSlots.previous}
                setPointer={setPointer}
                onNavigate={navigate}
                focusDuration={focusDuration}
            />
            <PeekTile
                slot={peekSlots.next}
                setPointer={setPointer}
                onNavigate={navigate}
                focusDuration={focusDuration}
            />
        </group>
    )
}

/*
 * Public component for the gallery.
 * Takes the images, the overlay slot and renders the WebGL scene.
 */
export function SphereGallery({
    items,
    className,
    onActiveChange,
    onReady,
    mode,
    priority,
    zIndex,
    transparent,
    ...rest
}: SphereGalleryProps) {
    const surface = useRef<ComponentRef<"div">>(null)
    const { lensBlur, ...sceneProps } = { ...DEFAULT_PROPS, ...rest }
    const [activeTile, setActiveTile] = useState<number | null>(null)

    const allyIndex = activeTile === null ? null : activeTile % items.length

    const select = useCallback((index: number) => {
        setActiveTile((current) => (current !== null ? null : index))
    }, [])

    const dismiss = useCallback(() => {
        setActiveTile(null)
    }, [])

    useEffect(() => {
        onActiveChange?.(allyIndex)
    }, [allyIndex, onActiveChange])

    useEffect(() => {
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.key === "Escape") dismiss()
        }
        window.addEventListener("keydown", onKeyDown)

        return () => {
            window.removeEventListener("keydown", onKeyDown)
        }
    }, [dismiss])

    return (
        <div ref={surface} className={`touch-none select-none ${className ?? ""}`}>
            {/* Basic SEO/accessibility layer */}
            <ul className="sr-only">
                {items.map((image, index) => (
                    <li key={image.src}>
                        <button
                            type="button"
                            aria-current={allyIndex === index}
                            onClick={() => {
                                if (activeTile !== null) setActiveTile(index)
                                else select(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}
                >
                    <PostProcessing
                        surface={surface}
                        strength={lensBlur}
                        active={activeTile !== null}
                    >
                        <SphereScene
                            {...sceneProps}
                            sources={items.map((image) => image.src)}
                            surface={surface}
                            activeTile={activeTile}
                            onSelect={select}
                            onNavigate={setActiveTile}
                            onDismiss={dismiss}
                            onReady={onReady}
                        />
                    </PostProcessing>
                </WebglScene>
            )}
        </div>
    )
}
webgl-scene.tsx
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
}

function WebglScenePortal({
    track,
    children,
    camera: propCamera,
    mode = "scissor",
    priority,
    zIndex = 0,
    transparent = true,
}: 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 { x, y, width, height } = bounds.current
        if (width === 0 || height === 0) return

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

        if (mode === "scissor") {
            const viewportLeft = x - window.scrollX
            const viewportTop = y - window.scrollY
            const canvasHeight = gl.domElement.clientHeight
            const canvasWidth = gl.domElement.clientWidth

            const previousAutoClear = gl.autoClear
            gl.autoClear = false
            gl.setViewport(viewportLeft, canvasHeight - (viewportTop + height), width, height)
            gl.setScissor(viewportLeft, canvasHeight - (viewportTop + 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 = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
            mesh.position.y = -(y + height / 2 - window.scrollY - 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 { Canvas, type CanvasProps, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
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
}

function Effects() {
    const effects = effectTeleport.useItems()
    if (effects.length === 0) 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}
            className={className}
            style={contained ? { position: "relative" } : { display: "contents" }}
        >
            <Canvas
                eventPrefix="client"
                dpr={[1, 1.5]}
                {...canvasProps}
                eventSource={eventSource ?? undefined}
                style={{
                    position: contained ? "absolute" : "fixed",
                    inset: 0,
                    pointerEvents: "none",
                    ...style,
                }}
            >
                <WebglPortal />
                <Effects />
            </Canvas>

            {children}
        </div>
    )
}

Usage

Add the WebglProvider once at the root of your app. See the installation guide for details.

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

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

Pass your images as an items array; they tile across the sphere to fill the grid. Each item is just a src and alt.

Basic
const ITEMS = [
  { src: "/a.jpg", alt: "Project A" },
  { src: "/b.jpg", alt: "Project B" },
  { src: "/c.jpg", alt: "Project C" },
]

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

Sizing

The size come from the element you render it into. Use className to decide of it's size.

Sizing
{/* Fills the viewport */}
<SphereGallery items={ITEMS} className="fixed inset-0 w-screen h-screen" />

{/* Sits in a sized container */}
<SphereGallery items={ITEMS} className="h-[500px] w-[500px]" />

Overlays

onActiveChange gives you the index of the opened image (and null when it closes). Use it to look up that item in your own data and render a caption, link, or anything else as regular HTML.

Overlay
const [active, setActive] = useState<number | null>(null)

<>
  {active !== null && (
    <a className="fixed bottom-8 left-1/2 -translate-x-1/2 text-white" href={ITEMS[active].href}>
      {ITEMS[active].title}
    </a>
  )}

  <SphereGallery items={ITEMS} onActiveChange={setActive} className="fixed inset-0" />
</>

Post-processing

Sphere Gallery renders through a WebGL Scene. By default that scene draws above other content. Set mode="texture" to render it into post-processing instead, so effects like FluidDistortion apply to the sphere.

Post-processing
<FluidDistortion />

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

API

NameTypeDefaultDescription
itemsSphereGalleryItem[]—The images to map onto the sphere, as { src, alt }. They tile to fill the grid.
revealbooleantrueWhen false, holds the reveal animation; flip to true to play it (e.g. after an intro).
revealDurationnumber2Duration of the reveal animation, in seconds.
focusDurationnumber1Duration of the click-to-focus zoom, in seconds.
focusScalenumber1.7How much a tile scales up when focused.
mouseParallaxnumber0.2Strength of the mouse-driven parallax tilt. 0 disables it.
onActiveChange(index: number | null) => void—Fires with the focused image index when one opens, or null when none is. Use it to render your own overlay from your own data.
rowsnumber7Horizontal tile rings stacked from bottom to top. Tile height follows from this.
columnsnumber12Sets the width of every tile. Higher values make tiles narrower and pack more around each ring.
latitudeRangenumber85Vertical spread of the rings in degrees.
gapnumber0.01Seam between neighboring tiles, in world units. 0 makes them touch.
paddingnumber0.03Even border between the tile edge and the contained image, in world units.
cornerRadiusnumber0.02Tile corner rounding in world units. 0 is square.
tileColorstring | null"#F8F8F8"Background color of the tile behind each image, shown in the padding and around images. Set to null to hide it so only the image shows.
sphereColorstring"#ffffff"Color of the sphere behind the tiles.
lensBlurnumber0.4Strength of the edge lens blur. 0 disables it; higher values blur more toward the edges.
fovnumber70Camera field of view in degrees. Narrows on click to zoom into an image.
classNamestring—Classes for the full-screen interaction surface.

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


Credits

React Three Fiber
React renderer for Three.js used for the WebGL scene.

Drei
Helper used to load the image textures.

Cosmos
Demo images sourced from the website. If you don't want your image shown in this demo, send me an email and I'll remove it.

  • Install
  • Usage
  • Sizing
  • Overlays
  • Post-processing
  • API
  • Credits
Star on githubBuy me a coffeellms.txt