A WebGL gallery that maps images onto a sphere. Drag to look around, click an image to zoom in.
npx atelier-ui add sphere-gallerynpm install three @react-three/fiber @react-three/drei motion"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>
)
}
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>
)
}
"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}
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>
)
}
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 }
## Integrate the <SphereGallery /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: SphereGallery
### Description: A WebGL gallery that maps images onto a sphere. Drag to look around, click an image to zoom in.
### Dependencies: three, @react-three/fiber, @react-three/drei, motion, @react-three/postprocessing, postprocessing
---
### Usage Example
Add the `WebglProvider` once at the root of your app. See the [installation guide](https://atelier-ui.com/docs/getting-started/installation) for details.
```tsx title="Root layout"
import { WebglProvider } from "@/components/webgl-provider";
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>;
}
```
Then use it anywhere in your app:
```tsx
const ITEMS = Array.from({ length: 20 }, (_, i) => ({
src: `https://picsum.photos/seed/atelier-${i + 1}/1200/800`,
alt: "",
}))
<SphereGallery items={ITEMS} className="fixed inset-0" />
```
### Overlays
`onActiveChange` gives you the index of the opened image, or `null` when it closes. Use it to render your own overlay as regular HTML:
```tsx
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
The gallery renders through a [WebGL Scene](https://atelier-ui.com/docs/components/primitive/webgl-scene) that draws above other content. Set `mode="texture"` to render it into post-processing instead, so effects like `FluidDistortion` apply to the sphere:
```tsx
<FluidDistortion />
<SphereGallery items={ITEMS} mode="texture" className="fixed inset-0" />
```
---
### Props
| Name | Type | Default | Description |
| ------------- | ----------- | ----------- | ----------------------------------------------------------------------------------------- |
| `items` | `SphereGalleryItem[]` | — | The images to map onto the sphere, as `{ src, alt }`. They tile to fill the grid. Required. |
| `reveal` | `boolean` | `true` | When `false`, holds the reveal animation; flip to `true` to play it (e.g. after an intro). |
| `revealDuration` | `number` | `2` | Duration of the reveal animation, in seconds. |
| `focusDuration` | `number` | `1` | Duration of the click-to-focus zoom, in seconds. |
| `focusScale` | `number` | `1.7` | How much a tile scales up when focused. |
| `mouseParallax` | `number` | `0.2` | Strength 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. |
| `rows` | `number` | `7` | Horizontal tile rings stacked from bottom to top. Tile height follows from this. |
| `columns` | `number` | `12` | Sets the width of every tile. Higher values make tiles narrower and pack more around each ring. |
| `latitudeRange` | `number` | `85` | Vertical spread of the rings in degrees. |
| `gap` | `number` | `0.01` | Seam between neighboring tiles, in world units. `0` makes them touch. |
| `padding` | `number` | `0.03` | Even border between the tile edge and the contained image, in world units. |
| `cornerRadius` | `number` | `0.02` | Tile corner rounding in world units. `0` is square. |
| `tileColor` | `string \| 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. |
| `sphereColor` | `string` | `"#ffffff"` | Color of the sphere behind the tiles. |
| `lensBlur` | `number` | `0.4` | Strength of the edge lens blur. `0` disables it; higher values blur more toward the edges. |
| `fov` | `number` | `70` | Camera field of view in degrees. Narrows on click to zoom into an image. |
| `className` | `string` | — | Classes for the interaction surface the gallery renders into. Sizes the gallery, any container size works. |
It also forwards [WebGL Scene](https://atelier-ui.com/docs/components/primitive/webgl-scene) props: `mode`, `priority`, `zIndex`, and `transparent`.
---
### Full Component Source
#### src/components/sphere-gallery/sphere-gallery.tsx
```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>
)
}
```
#### src/components/webgl-scene/webgl-scene.tsx
```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>
)
}
```
#### src/components/webgl-provider/webgl-provider.tsx
```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}
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>
)
}
```
#### src/components/webgl-portal/webgl-portal.tsx
```tsx
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 }
```
---
### Integration Instructions
1. If you can execute shell commands, run `npx atelier-ui add sphere-gallery` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: three, @react-three/fiber, @react-three/drei, motion, @react-three/postprocessing, postprocessing.
3. Copy each file from the component source above to the exact path shown.
4. Add the `WebglProvider` once at the app root as shown in the usage example (skip if one is already there - never add a second one).
5. Render `<SphereGallery />` where it belongs in the app, using the usage example as a starting point and the props table to adjust it.
Full documentation: https://atelier-ui.com/en/docs/components/background/sphere-galleryAdd the WebglProvider once at the root of your app. See the installation guide for details.
import { WebglProvider } from "@/components/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: "",
}))
<SphereGallery items={ITEMS} className="fixed inset-0" />onActiveChange gives you the index of the opened image, or null when it closes. Use it to render your own overlay as regular HTML:
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" />
</>The gallery renders through a WebGL Scene that draws above other content. Set mode="texture" to render it into post-processing instead, so effects like FluidDistortion apply to the sphere:
<FluidDistortion />
<SphereGallery items={ITEMS} mode="texture" className="fixed inset-0" />| Name | Type | Default | Description |
|---|---|---|---|
items | SphereGalleryItem[] | — | The images to map onto the sphere, as { src, alt }. They tile to fill the grid. Required. |
reveal | boolean | true | When false, holds the reveal animation; flip to true to play it (e.g. after an intro). |
revealDuration | number | 2 | Duration of the reveal animation, in seconds. |
focusDuration | number | 1 | Duration of the click-to-focus zoom, in seconds. |
focusScale | number | 1.7 | How much a tile scales up when focused. |
mouseParallax | number | 0.2 | Strength 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. |
rows | number | 7 | Horizontal tile rings stacked from bottom to top. Tile height follows from this. |
columns | number | 12 | Sets the width of every tile. Higher values make tiles narrower and pack more around each ring. |
latitudeRange | number | 85 | Vertical spread of the rings in degrees. |
gap | number | 0.01 | Seam between neighboring tiles, in world units. 0 makes them touch. |
padding | number | 0.03 | Even border between the tile edge and the contained image, in world units. |
cornerRadius | number | 0.02 | Tile corner rounding in world units. 0 is square. |
tileColor | string | 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. |
sphereColor | string | "#ffffff" | Color of the sphere behind the tiles. |
lensBlur | number | 0.4 | Strength of the edge lens blur. 0 disables it; higher values blur more toward the edges. |
fov | number | 70 | Camera field of view in degrees. Narrows on click to zoom into an image. |
className | string | — | 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, and transparent.
React Three Fiber
React renderer for Three.js used for the WebGL scene.
Drei
Helper used to load the image textures.