A WebGL gallery that places images on rotating rings. Scroll to spin them, click an image to zoom in.
npx atelier-ui add orbit-gallerynpm install three @react-three/fiber @react-three/drei motion"use client"
import { shaderMaterial, useTexture } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import type { Easing } from "motion"
import { animate } from "motion/react"
import {
type ComponentRef,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import * as THREE from "three"
import { useWebglReady } from "../webgl-provider/webgl-provider"
import { WebglScene, type WebglSceneProps } from "../webgl-scene/webgl-scene"
const TAU = Math.PI * 2
const ANIMATION_EASING = [0.7, 0, 0.1, 1] as Easing
const REVEAL_SPEED_BOOST = 50
const SELECT_SPEED_BOOST = 10
const RING_DOWNSCALE = 0.8
const TILE_ASPECT = 0.8
const FOCUS_TILE_SIZE = 5
const FOCUS_TILE_HIDDEN_SCALE = 2
const DISTORTION_AMOUNT = 0.5
const DISPERSION_AMOUNT = 5
const DEFAULT_PROPS = {
radius: 2.8,
rings: 3,
ringGap: 1.6,
tileHeight: 0.7,
cornerRadius: 0.08,
spinSpeed: 1,
spinStagger: 0.2,
wheel: true,
wheelMultiplier: 3,
revealDuration: 2,
focusDuration: 1,
}
type TileProps = {
texture: THREE.Texture
angle: number
radius: number
isSelected: boolean
ready: boolean
onSelect: () => void
} & Pick<typeof DEFAULT_PROPS, "tileHeight" | "cornerRadius" | "revealDuration" | "focusDuration">
type RingProps = {
radius: number
count: number
offset: number
speed: number
scale: number
textures: THREE.Texture[]
isSelected: boolean
ready: boolean
onSelect: (index: number) => void
speedFactor: { current: number }
revealBoost: { current: number }
} & Pick<typeof DEFAULT_PROPS, "tileHeight" | "cornerRadius" | "revealDuration" | "focusDuration">
type FocusTileProps = {
texture: THREE.Texture | null
cornerRadius: number
focusDuration: number
onDismiss: () => void
}
type OrbitSceneProps = {
sources: string[]
surface: RefObject<HTMLElement | null>
activeIndex: number | null
onSelect: (index: number) => void
onDismiss: () => void
onReady?: () => void
} & typeof DEFAULT_PROPS
type PlaneMesh<T extends THREE.Material> = THREE.Mesh<THREE.PlaneGeometry, T>
export type OrbitGalleryProps = {
items: {
src: string
alt: string
}[]
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 {
orbitTileMaterial: ThreeElement<typeof OrbitTileMaterial>
}
}
/*
* Shader material for each tile.
* Draws the image, the rounded mask and the dispersion blur.
*/
const OrbitTileMaterial = shaderMaterial(
{
uMap: new THREE.Texture(),
uTileSize: new THREE.Vector2(1, 1),
uUvScale: new THREE.Vector2(1, 1),
uUvOffset: new THREE.Vector2(0, 0),
uRadius: 0,
uOpacity: 1,
uReveal: 1,
uDistortion: 0,
uDispersion: 0,
},
/* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
/* glsl */ `
uniform sampler2D uMap;
uniform vec2 uTileSize;
uniform vec2 uUvScale;
uniform vec2 uUvOffset;
uniform float uRadius;
uniform float uOpacity;
uniform float uReveal;
uniform float uDistortion;
uniform float uDispersion;
varying vec2 vUv;
const int BLUR_SAMPLES = 16;
const float RGB_SHIFT = 0.35;
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;
}
vec4 sampleMap(vec2 uv) {
return texture2D(uMap, uUvOffset + uv * uUvScale);
}
float roundBoxMask(vec2 uv) {
vec2 point = (uv - 0.5) * uTileSize;
vec2 halfSize = uTileSize * 0.5;
float radius = min(uRadius, min(halfSize.x, halfSize.y));
float boxDistance = sdRoundBox(point, halfSize, radius);
float boxAntialias = fwidth(boxDistance);
return smoothstep(boxAntialias, -boxAntialias, boxDistance);
}
void main() {
vec2 centered = vUv - 0.5;
vec2 uv = 0.5 + centered * (1.0 + uDistortion * (0.5 - dot(centered, centered)));
vec4 texel = sampleMap(uv);
vec3 color = texel.rgb;
float alpha = texel.a;
if (uDispersion > 0.0) {
vec2 offset = uv - 0.5;
float amount = uDispersion * dot(centered, centered);
vec3 blurred = vec3(0.0);
float total = 0.0;
for (int sampleIndex = 0; sampleIndex < BLUR_SAMPLES; sampleIndex++) {
float progress = float(sampleIndex) / float(BLUR_SAMPLES - 1);
float weight = 1.0 - progress * 0.6;
float scale = 1.0 - amount * progress;
float spread = RGB_SHIFT * amount * progress;
blurred.r += sampleMap(0.5 + offset * (scale + spread)).r * weight;
blurred.g += sampleMap(0.5 + offset * scale).g * weight;
blurred.b += sampleMap(0.5 + offset * (scale - spread)).b * weight;
total += weight;
}
color = blurred / total;
}
float mask = roundBoxMask(uv);
alpha *= mask;
gl_FragColor = vec4(color, alpha * uOpacity * uReveal);
}
`,
)
extend({ OrbitTileMaterial })
/*
* Image tile placed on a ring.
* Handles the reveal, the fade and hover (opacity) transitions and click selection.
*/
function Tile({
texture,
angle,
radius,
tileHeight,
cornerRadius,
isSelected,
ready,
revealDuration,
focusDuration,
onSelect,
}: TileProps) {
const [hovered, setHovered] = useState(false)
const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)
const wasSelected = useRef(isSelected)
const width = tileHeight * TILE_ASPECT
const crop = useMemo(() => {
const image = texture.image as HTMLImageElement
const imageAspect = image.width / image.height
const scale =
imageAspect > TILE_ASPECT
? new THREE.Vector2(TILE_ASPECT / imageAspect, 1)
: new THREE.Vector2(1, imageAspect / TILE_ASPECT)
return {
scale,
offset: new THREE.Vector2((1 - scale.x) / 2, (1 - scale.y) / 2),
}
}, [texture])
const tilePlacement = useMemo(() => {
return {
position: new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 0),
rotation: angle - Math.PI / 2,
}
}, [angle, radius])
useEffect(() => {
function tileFadeAnimation() {
const material = meshRef.current?.material
if (!material) return
const selectionChanged = wasSelected.current !== isSelected
wasSelected.current = isSelected
const controls = animate(
material,
{ uOpacity: isSelected ? 0 : hovered ? 0.7 : 1 },
selectionChanged
? { duration: focusDuration * 0.3, ease: ANIMATION_EASING, delay: 0.2 }
: { duration: hovered ? 0.2 : 0.3 },
)
return () => controls.stop()
}
return tileFadeAnimation()
}, [isSelected, hovered, focusDuration])
useEffect(() => {
if (!ready) return
function tileRevealAnimation() {
const material = meshRef.current?.material
if (!material) return
const controls = animate(
material,
{ uReveal: 1 },
{ duration: revealDuration, ease: ANIMATION_EASING },
)
return () => controls.stop()
}
return tileRevealAnimation()
}, [ready, revealDuration])
return (
<group position={tilePlacement.position} rotation-z={tilePlacement.rotation}>
<mesh
ref={meshRef}
raycast={isSelected ? () => null : THREE.Mesh.prototype.raycast}
onClick={(event) => {
event.stopPropagation()
onSelect()
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[width, tileHeight]} />
<orbitTileMaterial
key={OrbitTileMaterial.key}
uMap={texture}
uTileSize={new THREE.Vector2(width, tileHeight)}
uUvScale={crop.scale}
uUvOffset={crop.offset}
uRadius={cornerRadius}
uReveal={0}
transparent
depthWrite={false}
/>
</mesh>
</group>
)
}
/*
* One rotating ring of tiles.
* Handles the spin and the fade-out when a tile is selected.
*/
function Ring({
textures,
radius,
count,
offset,
speed,
scale: ringScale,
tileHeight,
cornerRadius,
isSelected,
ready,
revealDuration,
focusDuration,
onSelect,
speedFactor,
revealBoost,
}: RingProps) {
const groupRef = useRef<THREE.Group>(null)
const selectBoost = useRef(0)
const tiles = useMemo(() => {
return Array.from({ length: count }, (_, index) => {
const textureIndex = (index + offset) % textures.length
return {
angle: (index / count) * TAU,
textureIndex,
texture: textures[textureIndex],
}
})
}, [count, offset, textures])
useEffect(() => {
function ringFadeAnimation() {
const group = groupRef.current
if (!group) return
const scale = isSelected ? ringScale : 1
const controls = animate([
[
group.scale,
{ x: scale, y: scale, z: scale },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING },
],
[
selectBoost,
{ current: isSelected ? SELECT_SPEED_BOOST : 1 },
{ duration: focusDuration * 0.3, ease: "linear", at: 0 },
],
])
return () => controls.stop()
}
return ringFadeAnimation()
}, [isSelected, ringScale, focusDuration])
useFrame((_, delta) => {
const group = groupRef.current
if (!group) return
const boost = selectBoost.current + revealBoost.current
const direction = Math.sign(speed)
const rmp = (speed + direction * boost) * speedFactor.current
group.rotation.z += (rmp * TAU * delta) / 60
})
return (
<group ref={groupRef}>
{tiles.map((tile, index) => (
<Tile
key={index}
texture={tile.texture}
angle={tile.angle}
radius={radius}
tileHeight={tileHeight}
cornerRadius={cornerRadius}
isSelected={isSelected}
ready={ready}
revealDuration={revealDuration}
focusDuration={focusDuration}
onSelect={() => onSelect(tile.textureIndex)}
/>
))}
</group>
)
}
/*
* Enlarged tile shown when an image is selected.
* Handles the zoom, distortion and fade transitions.
*/
function FocusTile({ texture, cornerRadius, focusDuration, onDismiss }: FocusTileProps) {
const [displayed, setDisplayed] = useState<THREE.Texture | null>(null)
const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)
useEffect(() => {
if (texture) setDisplayed(texture)
}, [texture])
useEffect(() => {
function focusTileFadeAnimation() {
const mesh = meshRef.current
if (!mesh) return
const scale = texture ? 1 : FOCUS_TILE_HIDDEN_SCALE
const controls = animate([
[
mesh.material,
{ uOpacity: texture ? 1 : 0 },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING },
],
[
mesh.material,
{ uDistortion: texture ? 0 : DISTORTION_AMOUNT },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },
],
[
mesh.material,
{ uDispersion: texture ? 0 : DISPERSION_AMOUNT },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },
],
[
mesh.scale,
{ x: scale, y: scale },
{ duration: focusDuration * 0.7, ease: ANIMATION_EASING, at: 0 },
],
])
if (!texture) controls.then(() => setDisplayed(null))
return () => controls.stop()
}
return focusTileFadeAnimation()
}, [texture, displayed, focusDuration])
if (!displayed) return null
const image = displayed.image as HTMLImageElement
const width = FOCUS_TILE_SIZE * (image.width / image.height)
return (
<mesh
ref={meshRef}
position-z={1}
scale={[FOCUS_TILE_HIDDEN_SCALE, FOCUS_TILE_HIDDEN_SCALE, 1]}
raycast={texture ? THREE.Mesh.prototype.raycast : () => null}
onPointerOver={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
onDismiss()
}}
>
<planeGeometry args={[width, FOCUS_TILE_SIZE]} />
<orbitTileMaterial
key={OrbitTileMaterial.key}
uMap={displayed}
uTileSize={new THREE.Vector2(width, FOCUS_TILE_SIZE)}
uRadius={cornerRadius}
uOpacity={0}
uDistortion={DISTORTION_AMOUNT}
uDispersion={DISPERSION_AMOUNT}
transparent
depthWrite={false}
/>
</mesh>
)
}
/*
* Builds the ring configs and loads the textures.
* Tracks the selected tile and handles dismissal.
*/
function OrbitScene({
sources,
surface,
activeIndex,
onSelect,
onDismiss,
radius,
rings,
ringGap,
tileHeight,
cornerRadius,
spinSpeed,
spinStagger,
wheel,
wheelMultiplier,
revealDuration,
focusDuration,
onReady,
}: OrbitSceneProps) {
const textures = useTexture(sources)
const speedFactor = useRef(1)
const revealBoost = useRef(REVEAL_SPEED_BOOST)
const ready = useWebglReady({ onReady })
const selected = activeIndex !== null ? textures[activeIndex] : null
const select = useCallback(
(index: number) => {
onSelect(index)
surface.current?.style.removeProperty("cursor")
},
[onSelect, surface],
)
useEffect(() => {
if (!ready) return
function revealSpinAnimation() {
const controls = animate(
revealBoost,
{ current: 0 },
{ duration: revealDuration, ease: ANIMATION_EASING },
)
return () => controls.stop()
}
return revealSpinAnimation()
}, [ready, revealDuration])
useEffect(() => {
const target = surface.current
if (!target || !wheel) return
const onWheel = (event: WheelEvent) => {
event.preventDefault()
speedFactor.current += event.deltaY * 0.01 * wheelMultiplier
}
target.addEventListener("wheel", onWheel)
return () => target.removeEventListener("wheel", onWheel)
}, [surface, wheel, wheelMultiplier])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [onDismiss])
useFrame((_, delta) => {
speedFactor.current = THREE.MathUtils.damp(
speedFactor.current,
Math.sign(speedFactor.current) || 1,
8,
delta,
)
})
const ringConfigs = useMemo(
() =>
Array.from({ length: rings }, (_, ring) => {
const ringRadius = radius + ring * ringGap
return {
radius: ringRadius,
count: Math.max(3, Math.round(sources.length * (ringRadius / radius))),
offset: Math.round((ring * sources.length) / rings),
speed: spinSpeed * spinStagger ** ring,
scale: 1 - RING_DOWNSCALE / (ring + 1),
}
}),
[radius, rings, ringGap, sources.length, spinSpeed, spinStagger],
)
return (
<group
onPointerMissed={onDismiss}
onPointerOver={() => surface.current?.style.setProperty("cursor", "pointer")}
onPointerOut={() => surface.current?.style.removeProperty("cursor")}
>
{ringConfigs.map((config, index) => (
<Ring
key={index}
textures={textures}
radius={config.radius}
count={config.count}
offset={config.offset}
speed={config.speed}
scale={config.scale}
tileHeight={tileHeight}
cornerRadius={cornerRadius}
isSelected={selected !== null}
ready={ready}
revealDuration={revealDuration}
focusDuration={focusDuration}
onSelect={select}
speedFactor={speedFactor}
revealBoost={revealBoost}
/>
))}
<FocusTile
texture={selected}
cornerRadius={cornerRadius}
focusDuration={focusDuration}
onDismiss={onDismiss}
/>
</group>
)
}
/*
* Public component for the gallery.
* Takes the images and renders the WebGL scene.
*/
export function OrbitGallery({
items,
className,
onActiveChange,
mode,
priority,
zIndex,
transparent,
...rest
}: OrbitGalleryProps) {
const surface = useRef<ComponentRef<"div">>(null)
const sceneProps = { ...DEFAULT_PROPS, ...rest }
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const dismiss = useCallback(() => {
setActiveIndex(null)
}, [])
useEffect(() => {
onActiveChange?.(activeIndex)
}, [activeIndex, onActiveChange])
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={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
<img src={image.src} alt={image.alt} />
</button>
</li>
))}
</ul>
{items.length > 0 && (
<WebglScene
track={surface}
mode={mode}
priority={priority}
zIndex={zIndex}
transparent={transparent}
>
<OrbitScene
{...sceneProps}
surface={surface}
sources={items.map((image) => image.src)}
activeIndex={activeIndex}
onSelect={setActiveIndex}
onDismiss={dismiss}
/>
</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 <OrbitGallery /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: OrbitGallery
### Description: A WebGL gallery that places images on rotating rings. Scroll to spin them, 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: "",
}))
<OrbitGallery 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 && (
<p className="fixed bottom-8 left-1/2 -translate-x-1/2 text-white">Image {active + 1}</p>
)}
<OrbitGallery 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 rings:
```tsx
<FluidDistortion />
<OrbitGallery items={ITEMS} mode="texture" className="fixed inset-0" />
```
---
### Props
| Name | Type | Default | Description |
| ----------- | --------------------- | ------- | --------------------------------------------------------------------------------- |
| `items` | `OrbitGalleryItem[]` | — | The images placed around the rings, as `{ src, alt }`. They repeat to fill each ring. Required. |
| `radius` | `number` | `2.8` | Radius of the innermost ring, in world units. |
| `rings` | `number` | `3` | Number of rings around the center. |
| `ringGap` | `number` | `1.6` | Distance between two neighboring rings, in world units. |
| `tileHeight` | `number` | `0.7` | Height of each image tile. Width follows from the image aspect ratio. |
| `cornerRadius`| `number` | `0.08` | Corner radius of each tile, in world units. `0` keeps square corners. |
| `spinSpeed` | `number` | `1` | Revolutions per minute of the innermost ring. Negative values spin the other way. |
| `spinStagger` | `number` | `0.2` | Speed ratio between one ring and the next. Below `1` outer rings spin slower, above `1` faster. |
| `wheel` | `boolean` | `true` | Whether the scroll wheel accelerates the spin. When `false` the wheel does nothing. |
| `wheelMultiplier` | `number` | `3` | How much the scroll wheel accelerates the spin. `0` disables it. |
| `revealDuration` | `number` | `2` | Duration of the reveal animation, in seconds. |
| `focusDuration` | `number` | `1` | Duration of the click-to-focus zoom and the ring fade, in seconds. |
| `onActiveChange` | `(index: number \| null) => void` | — | Fires with the opened image index when one opens, or `null` when none is. Use it to render your own overlay from your own data. |
| `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/orbit-gallery/orbit-gallery.tsx
```tsx
"use client"
import { shaderMaterial, useTexture } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import type { Easing } from "motion"
import { animate } from "motion/react"
import {
type ComponentRef,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import * as THREE from "three"
import { useWebglReady } from "../webgl-provider/webgl-provider"
import { WebglScene, type WebglSceneProps } from "../webgl-scene/webgl-scene"
const TAU = Math.PI * 2
const ANIMATION_EASING = [0.7, 0, 0.1, 1] as Easing
const REVEAL_SPEED_BOOST = 50
const SELECT_SPEED_BOOST = 10
const RING_DOWNSCALE = 0.8
const TILE_ASPECT = 0.8
const FOCUS_TILE_SIZE = 5
const FOCUS_TILE_HIDDEN_SCALE = 2
const DISTORTION_AMOUNT = 0.5
const DISPERSION_AMOUNT = 5
const DEFAULT_PROPS = {
radius: 2.8,
rings: 3,
ringGap: 1.6,
tileHeight: 0.7,
cornerRadius: 0.08,
spinSpeed: 1,
spinStagger: 0.2,
wheel: true,
wheelMultiplier: 3,
revealDuration: 2,
focusDuration: 1,
}
type TileProps = {
texture: THREE.Texture
angle: number
radius: number
isSelected: boolean
ready: boolean
onSelect: () => void
} & Pick<typeof DEFAULT_PROPS, "tileHeight" | "cornerRadius" | "revealDuration" | "focusDuration">
type RingProps = {
radius: number
count: number
offset: number
speed: number
scale: number
textures: THREE.Texture[]
isSelected: boolean
ready: boolean
onSelect: (index: number) => void
speedFactor: { current: number }
revealBoost: { current: number }
} & Pick<typeof DEFAULT_PROPS, "tileHeight" | "cornerRadius" | "revealDuration" | "focusDuration">
type FocusTileProps = {
texture: THREE.Texture | null
cornerRadius: number
focusDuration: number
onDismiss: () => void
}
type OrbitSceneProps = {
sources: string[]
surface: RefObject<HTMLElement | null>
activeIndex: number | null
onSelect: (index: number) => void
onDismiss: () => void
onReady?: () => void
} & typeof DEFAULT_PROPS
type PlaneMesh<T extends THREE.Material> = THREE.Mesh<THREE.PlaneGeometry, T>
export type OrbitGalleryProps = {
items: {
src: string
alt: string
}[]
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 {
orbitTileMaterial: ThreeElement<typeof OrbitTileMaterial>
}
}
/*
* Shader material for each tile.
* Draws the image, the rounded mask and the dispersion blur.
*/
const OrbitTileMaterial = shaderMaterial(
{
uMap: new THREE.Texture(),
uTileSize: new THREE.Vector2(1, 1),
uUvScale: new THREE.Vector2(1, 1),
uUvOffset: new THREE.Vector2(0, 0),
uRadius: 0,
uOpacity: 1,
uReveal: 1,
uDistortion: 0,
uDispersion: 0,
},
/* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
/* glsl */ `
uniform sampler2D uMap;
uniform vec2 uTileSize;
uniform vec2 uUvScale;
uniform vec2 uUvOffset;
uniform float uRadius;
uniform float uOpacity;
uniform float uReveal;
uniform float uDistortion;
uniform float uDispersion;
varying vec2 vUv;
const int BLUR_SAMPLES = 16;
const float RGB_SHIFT = 0.35;
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;
}
vec4 sampleMap(vec2 uv) {
return texture2D(uMap, uUvOffset + uv * uUvScale);
}
float roundBoxMask(vec2 uv) {
vec2 point = (uv - 0.5) * uTileSize;
vec2 halfSize = uTileSize * 0.5;
float radius = min(uRadius, min(halfSize.x, halfSize.y));
float boxDistance = sdRoundBox(point, halfSize, radius);
float boxAntialias = fwidth(boxDistance);
return smoothstep(boxAntialias, -boxAntialias, boxDistance);
}
void main() {
vec2 centered = vUv - 0.5;
vec2 uv = 0.5 + centered * (1.0 + uDistortion * (0.5 - dot(centered, centered)));
vec4 texel = sampleMap(uv);
vec3 color = texel.rgb;
float alpha = texel.a;
if (uDispersion > 0.0) {
vec2 offset = uv - 0.5;
float amount = uDispersion * dot(centered, centered);
vec3 blurred = vec3(0.0);
float total = 0.0;
for (int sampleIndex = 0; sampleIndex < BLUR_SAMPLES; sampleIndex++) {
float progress = float(sampleIndex) / float(BLUR_SAMPLES - 1);
float weight = 1.0 - progress * 0.6;
float scale = 1.0 - amount * progress;
float spread = RGB_SHIFT * amount * progress;
blurred.r += sampleMap(0.5 + offset * (scale + spread)).r * weight;
blurred.g += sampleMap(0.5 + offset * scale).g * weight;
blurred.b += sampleMap(0.5 + offset * (scale - spread)).b * weight;
total += weight;
}
color = blurred / total;
}
float mask = roundBoxMask(uv);
alpha *= mask;
gl_FragColor = vec4(color, alpha * uOpacity * uReveal);
}
`,
)
extend({ OrbitTileMaterial })
/*
* Image tile placed on a ring.
* Handles the reveal, the fade and hover (opacity) transitions and click selection.
*/
function Tile({
texture,
angle,
radius,
tileHeight,
cornerRadius,
isSelected,
ready,
revealDuration,
focusDuration,
onSelect,
}: TileProps) {
const [hovered, setHovered] = useState(false)
const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)
const wasSelected = useRef(isSelected)
const width = tileHeight * TILE_ASPECT
const crop = useMemo(() => {
const image = texture.image as HTMLImageElement
const imageAspect = image.width / image.height
const scale =
imageAspect > TILE_ASPECT
? new THREE.Vector2(TILE_ASPECT / imageAspect, 1)
: new THREE.Vector2(1, imageAspect / TILE_ASPECT)
return {
scale,
offset: new THREE.Vector2((1 - scale.x) / 2, (1 - scale.y) / 2),
}
}, [texture])
const tilePlacement = useMemo(() => {
return {
position: new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 0),
rotation: angle - Math.PI / 2,
}
}, [angle, radius])
useEffect(() => {
function tileFadeAnimation() {
const material = meshRef.current?.material
if (!material) return
const selectionChanged = wasSelected.current !== isSelected
wasSelected.current = isSelected
const controls = animate(
material,
{ uOpacity: isSelected ? 0 : hovered ? 0.7 : 1 },
selectionChanged
? { duration: focusDuration * 0.3, ease: ANIMATION_EASING, delay: 0.2 }
: { duration: hovered ? 0.2 : 0.3 },
)
return () => controls.stop()
}
return tileFadeAnimation()
}, [isSelected, hovered, focusDuration])
useEffect(() => {
if (!ready) return
function tileRevealAnimation() {
const material = meshRef.current?.material
if (!material) return
const controls = animate(
material,
{ uReveal: 1 },
{ duration: revealDuration, ease: ANIMATION_EASING },
)
return () => controls.stop()
}
return tileRevealAnimation()
}, [ready, revealDuration])
return (
<group position={tilePlacement.position} rotation-z={tilePlacement.rotation}>
<mesh
ref={meshRef}
raycast={isSelected ? () => null : THREE.Mesh.prototype.raycast}
onClick={(event) => {
event.stopPropagation()
onSelect()
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[width, tileHeight]} />
<orbitTileMaterial
key={OrbitTileMaterial.key}
uMap={texture}
uTileSize={new THREE.Vector2(width, tileHeight)}
uUvScale={crop.scale}
uUvOffset={crop.offset}
uRadius={cornerRadius}
uReveal={0}
transparent
depthWrite={false}
/>
</mesh>
</group>
)
}
/*
* One rotating ring of tiles.
* Handles the spin and the fade-out when a tile is selected.
*/
function Ring({
textures,
radius,
count,
offset,
speed,
scale: ringScale,
tileHeight,
cornerRadius,
isSelected,
ready,
revealDuration,
focusDuration,
onSelect,
speedFactor,
revealBoost,
}: RingProps) {
const groupRef = useRef<THREE.Group>(null)
const selectBoost = useRef(0)
const tiles = useMemo(() => {
return Array.from({ length: count }, (_, index) => {
const textureIndex = (index + offset) % textures.length
return {
angle: (index / count) * TAU,
textureIndex,
texture: textures[textureIndex],
}
})
}, [count, offset, textures])
useEffect(() => {
function ringFadeAnimation() {
const group = groupRef.current
if (!group) return
const scale = isSelected ? ringScale : 1
const controls = animate([
[
group.scale,
{ x: scale, y: scale, z: scale },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING },
],
[
selectBoost,
{ current: isSelected ? SELECT_SPEED_BOOST : 1 },
{ duration: focusDuration * 0.3, ease: "linear", at: 0 },
],
])
return () => controls.stop()
}
return ringFadeAnimation()
}, [isSelected, ringScale, focusDuration])
useFrame((_, delta) => {
const group = groupRef.current
if (!group) return
const boost = selectBoost.current + revealBoost.current
const direction = Math.sign(speed)
const rmp = (speed + direction * boost) * speedFactor.current
group.rotation.z += (rmp * TAU * delta) / 60
})
return (
<group ref={groupRef}>
{tiles.map((tile, index) => (
<Tile
key={index}
texture={tile.texture}
angle={tile.angle}
radius={radius}
tileHeight={tileHeight}
cornerRadius={cornerRadius}
isSelected={isSelected}
ready={ready}
revealDuration={revealDuration}
focusDuration={focusDuration}
onSelect={() => onSelect(tile.textureIndex)}
/>
))}
</group>
)
}
/*
* Enlarged tile shown when an image is selected.
* Handles the zoom, distortion and fade transitions.
*/
function FocusTile({ texture, cornerRadius, focusDuration, onDismiss }: FocusTileProps) {
const [displayed, setDisplayed] = useState<THREE.Texture | null>(null)
const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)
useEffect(() => {
if (texture) setDisplayed(texture)
}, [texture])
useEffect(() => {
function focusTileFadeAnimation() {
const mesh = meshRef.current
if (!mesh) return
const scale = texture ? 1 : FOCUS_TILE_HIDDEN_SCALE
const controls = animate([
[
mesh.material,
{ uOpacity: texture ? 1 : 0 },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING },
],
[
mesh.material,
{ uDistortion: texture ? 0 : DISTORTION_AMOUNT },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },
],
[
mesh.material,
{ uDispersion: texture ? 0 : DISPERSION_AMOUNT },
{ duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },
],
[
mesh.scale,
{ x: scale, y: scale },
{ duration: focusDuration * 0.7, ease: ANIMATION_EASING, at: 0 },
],
])
if (!texture) controls.then(() => setDisplayed(null))
return () => controls.stop()
}
return focusTileFadeAnimation()
}, [texture, displayed, focusDuration])
if (!displayed) return null
const image = displayed.image as HTMLImageElement
const width = FOCUS_TILE_SIZE * (image.width / image.height)
return (
<mesh
ref={meshRef}
position-z={1}
scale={[FOCUS_TILE_HIDDEN_SCALE, FOCUS_TILE_HIDDEN_SCALE, 1]}
raycast={texture ? THREE.Mesh.prototype.raycast : () => null}
onPointerOver={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
onDismiss()
}}
>
<planeGeometry args={[width, FOCUS_TILE_SIZE]} />
<orbitTileMaterial
key={OrbitTileMaterial.key}
uMap={displayed}
uTileSize={new THREE.Vector2(width, FOCUS_TILE_SIZE)}
uRadius={cornerRadius}
uOpacity={0}
uDistortion={DISTORTION_AMOUNT}
uDispersion={DISPERSION_AMOUNT}
transparent
depthWrite={false}
/>
</mesh>
)
}
/*
* Builds the ring configs and loads the textures.
* Tracks the selected tile and handles dismissal.
*/
function OrbitScene({
sources,
surface,
activeIndex,
onSelect,
onDismiss,
radius,
rings,
ringGap,
tileHeight,
cornerRadius,
spinSpeed,
spinStagger,
wheel,
wheelMultiplier,
revealDuration,
focusDuration,
onReady,
}: OrbitSceneProps) {
const textures = useTexture(sources)
const speedFactor = useRef(1)
const revealBoost = useRef(REVEAL_SPEED_BOOST)
const ready = useWebglReady({ onReady })
const selected = activeIndex !== null ? textures[activeIndex] : null
const select = useCallback(
(index: number) => {
onSelect(index)
surface.current?.style.removeProperty("cursor")
},
[onSelect, surface],
)
useEffect(() => {
if (!ready) return
function revealSpinAnimation() {
const controls = animate(
revealBoost,
{ current: 0 },
{ duration: revealDuration, ease: ANIMATION_EASING },
)
return () => controls.stop()
}
return revealSpinAnimation()
}, [ready, revealDuration])
useEffect(() => {
const target = surface.current
if (!target || !wheel) return
const onWheel = (event: WheelEvent) => {
event.preventDefault()
speedFactor.current += event.deltaY * 0.01 * wheelMultiplier
}
target.addEventListener("wheel", onWheel)
return () => target.removeEventListener("wheel", onWheel)
}, [surface, wheel, wheelMultiplier])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [onDismiss])
useFrame((_, delta) => {
speedFactor.current = THREE.MathUtils.damp(
speedFactor.current,
Math.sign(speedFactor.current) || 1,
8,
delta,
)
})
const ringConfigs = useMemo(
() =>
Array.from({ length: rings }, (_, ring) => {
const ringRadius = radius + ring * ringGap
return {
radius: ringRadius,
count: Math.max(3, Math.round(sources.length * (ringRadius / radius))),
offset: Math.round((ring * sources.length) / rings),
speed: spinSpeed * spinStagger ** ring,
scale: 1 - RING_DOWNSCALE / (ring + 1),
}
}),
[radius, rings, ringGap, sources.length, spinSpeed, spinStagger],
)
return (
<group
onPointerMissed={onDismiss}
onPointerOver={() => surface.current?.style.setProperty("cursor", "pointer")}
onPointerOut={() => surface.current?.style.removeProperty("cursor")}
>
{ringConfigs.map((config, index) => (
<Ring
key={index}
textures={textures}
radius={config.radius}
count={config.count}
offset={config.offset}
speed={config.speed}
scale={config.scale}
tileHeight={tileHeight}
cornerRadius={cornerRadius}
isSelected={selected !== null}
ready={ready}
revealDuration={revealDuration}
focusDuration={focusDuration}
onSelect={select}
speedFactor={speedFactor}
revealBoost={revealBoost}
/>
))}
<FocusTile
texture={selected}
cornerRadius={cornerRadius}
focusDuration={focusDuration}
onDismiss={onDismiss}
/>
</group>
)
}
/*
* Public component for the gallery.
* Takes the images and renders the WebGL scene.
*/
export function OrbitGallery({
items,
className,
onActiveChange,
mode,
priority,
zIndex,
transparent,
...rest
}: OrbitGalleryProps) {
const surface = useRef<ComponentRef<"div">>(null)
const sceneProps = { ...DEFAULT_PROPS, ...rest }
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const dismiss = useCallback(() => {
setActiveIndex(null)
}, [])
useEffect(() => {
onActiveChange?.(activeIndex)
}, [activeIndex, onActiveChange])
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={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
<img src={image.src} alt={image.alt} />
</button>
</li>
))}
</ul>
{items.length > 0 && (
<WebglScene
track={surface}
mode={mode}
priority={priority}
zIndex={zIndex}
transparent={transparent}
>
<OrbitScene
{...sceneProps}
surface={surface}
sources={items.map((image) => image.src)}
activeIndex={activeIndex}
onSelect={setActiveIndex}
onDismiss={dismiss}
/>
</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 orbit-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 `<OrbitGallery />` 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/orbit-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: "",
}))
<OrbitGallery 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 && (
<p className="fixed bottom-8 left-1/2 -translate-x-1/2 text-white">Image {active + 1}</p>
)}
<OrbitGallery 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 rings:
<FluidDistortion />
<OrbitGallery items={ITEMS} mode="texture" className="fixed inset-0" />| Name | Type | Default | Description |
|---|---|---|---|
items | OrbitGalleryItem[] | — | The images placed around the rings, as { src, alt }. They repeat to fill each ring. Required. |
radius | number | 2.8 | Radius of the innermost ring, in world units. |
rings | number | 3 | Number of rings around the center. |
ringGap | number | 1.6 | Distance between two neighboring rings, in world units. |
tileHeight | number | 0.7 | Height of each image tile. Width follows from the image aspect ratio. |
cornerRadius | number | 0.08 | Corner radius of each tile, in world units. 0 keeps square corners. |
spinSpeed | number | 1 | Revolutions per minute of the innermost ring. Negative values spin the other way. |
spinStagger | number | 0.2 | Speed ratio between one ring and the next. Below 1 outer rings spin slower, above 1 faster. |
wheel | boolean | true | Whether the scroll wheel accelerates the spin. When false the wheel does nothing. |
wheelMultiplier | number | 3 | How much the scroll wheel accelerates the spin. 0 disables it. |
revealDuration | number | 2 | Duration of the reveal animation, in seconds. |
focusDuration | number | 1 | Duration of the click-to-focus zoom and the ring fade, in seconds. |
onActiveChange | (index: number | null) => void | — | Fires with the opened image index when one opens, or null when none is. Use it to render your own overlay from your own data. |
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.