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 MOTION_BLUR_AMOUNT = 0.01
const RING_DOWNSCALE = 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,
wheelMultiplier: 3,
revealDuration: 2,
focusDuration: 1,
}
type TileProps = {
texture: THREE.Texture
angle: number
radius: number
isSelected: boolean
ready: boolean
onSelect: () => void
motionBlur: { current: number }
} & 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: (texture: THREE.Texture) => 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>
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
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, the dispersion blur and the reveal motion blur.
*/
const OrbitTileMaterial = shaderMaterial(
{
uMap: new THREE.Texture(),
uTileSize: new THREE.Vector2(1, 1),
uRadius: 0,
uOpacity: 1,
uReveal: 1,
uMotionBlur: 0,
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 float uRadius;
uniform float uOpacity;
uniform float uReveal;
uniform float uMotionBlur;
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;
}
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 = texture2D(uMap, 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 += texture2D(uMap, 0.5 + offset * (scale + spread)).r * weight;
blurred.g += texture2D(uMap, 0.5 + offset * scale).g * weight;
blurred.b += texture2D(uMap, 0.5 + offset * (scale - spread)).b * weight;
total += weight;
}
color = blurred / total;
}
float mask = roundBoxMask(uv);
if (uMotionBlur > 0.0) {
float spread = uMotionBlur / uTileSize.x;
vec3 smeared = vec3(0.0);
float smearedMask = 0.0;
for (int sampleIndex = 0; sampleIndex < BLUR_SAMPLES; sampleIndex++) {
float progress = float(sampleIndex) / float(BLUR_SAMPLES - 1) - 0.5;
vec2 sampleUv = uv + vec2(progress * spread, 0.0);
smeared += texture2D(uMap, sampleUv).rgb;
smearedMask += roundBoxMask(sampleUv);
}
color = smeared / float(BLUR_SAMPLES);
mask = smearedMask / float(BLUR_SAMPLES);
}
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,
motionBlur,
}: TileProps) {
const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)
const [hovered, setHovered] = useState(false)
const wasSelected = useRef(isSelected)
const image = texture.image as HTMLImageElement
const width = tileHeight * (image.width / image.height)
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])
useFrame(() => {
const material = meshRef.current?.material
if (!material) return
material.uMotionBlur = motionBlur.current
})
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)}
uRadius={cornerRadius}
uReveal={0}
transparent
depthWrite={false}
/>
</mesh>
</group>
)
}
/*
* One rotating ring of tiles.
* Handles the spin, the speed-based motion blur 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 motionBlur = useRef(0)
const tiles = useMemo(() => {
return Array.from({ length: count }, (_, index) => ({
angle: (index / count) * TAU,
texture: textures[(index + offset) % textures.length],
}))
}, [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
const idleSpeed = Math.abs(speed) + 1
const extraSpeed = Math.max(0, Math.abs(rmp) - idleSpeed)
const blur = MOTION_BLUR_AMOUNT * extraSpeed * radius
motionBlur.current = blur < 0.001 ? 0 : blur
})
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.texture)}
motionBlur={motionBlur}
/>
))}
</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,
radius,
rings,
ringGap,
tileHeight,
cornerRadius,
spinSpeed,
spinStagger,
wheelMultiplier,
revealDuration,
focusDuration,
onReady,
}: OrbitSceneProps) {
const [selected, setSelected] = useState<THREE.Texture | null>(null)
const textures = useTexture(sources)
const speedFactor = useRef(1)
const revealBoost = useRef(REVEAL_SPEED_BOOST)
const ready = useWebglReady({ onReady })
const dismiss = useCallback(() => setSelected(null), [])
const select = useCallback(
(texture: THREE.Texture) => {
setSelected(texture)
surface.current?.style.removeProperty("cursor")
},
[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) return
const onWheel = (event: WheelEvent) => {
event.preventDefault()
speedFactor.current += event.deltaY * 0.01 * wheelMultiplier
}
target.addEventListener("wheel", onWheel)
return () => target.removeEventListener("wheel", onWheel)
}, [surface, wheelMultiplier])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [dismiss])
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={dismiss}
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={dismiss}
/>
</group>
)
}
/*
* Public component for the gallery.
* Takes the images and renders the WebGL scene.
*/
export function OrbitGallery({
items,
className,
mode,
priority,
zIndex,
transparent,
...rest
}: OrbitGalleryProps) {
const surface = useRef<ComponentRef<"div">>(null)
const sceneProps = { ...DEFAULT_PROPS, ...rest }
return (
<div ref={surface} className={`touch-none select-none ${className ?? ""}`}>
{/* Basic SEO/accessibility layer */}
<ul className="sr-only">
{items.map((image) => (
<li key={image.src}>
<img src={image.src} alt={image.alt} />
</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)}
/>
</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 { Canvas, type CanvasProps, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
import { type ComponentRef, type ReactNode, useEffect, useRef, useState } from "react"
import type { Camera, Scene } from "three"
import { effectTeleport, WebglPortal } from "../webgl-portal/webgl-portal"
type WebglProviderProps = Omit<CanvasProps, "children" | "eventSource"> & {
children: ReactNode
className?: string
contained?: boolean
}
type WebglReadyOptions = {
scene?: Scene
camera?: Camera
enabled?: boolean
onReady?: () => void
}
export function useWebglReady({ scene, camera, enabled = true, onReady }: WebglReadyOptions = {}) {
const [ready, setReady] = useState(false)
const gl = useThree((state) => state.gl)
const defaultScene = useThree((state) => state.scene)
const defaultCamera = useThree((state) => state.camera)
const onReadyRef = useRef(onReady)
onReadyRef.current = onReady
const targetScene = scene ?? defaultScene
const targetCamera = camera ?? defaultCamera
useEffect(() => {
if (!enabled) return
let active = true
gl.compileAsync(targetScene, targetCamera).then(() => {
if (!active) return
requestAnimationFrame(() => {
if (!active) return
setReady(true)
onReadyRef.current?.()
})
})
return () => {
active = false
}
}, [gl, targetScene, targetCamera, enabled])
return ready
}
function Effects() {
const effects = effectTeleport.useItems()
if (effects.length === 0) return null
return (
<EffectComposer key={effects.length}>
<effectTeleport.Out />
</EffectComposer>
)
}
export function WebglProvider({
children,
className,
style,
contained = false,
...canvasProps
}: WebglProviderProps) {
const [eventSource, setEventSource] = useState<ComponentRef<"div"> | null>(null)
return (
<div
ref={setEventSource}
className={className}
style={contained ? { position: "relative" } : { display: "contents" }}
>
<Canvas
eventPrefix="client"
dpr={[1, 1.5]}
{...canvasProps}
eventSource={eventSource ?? undefined}
style={{
position: contained ? "absolute" : "fixed",
inset: 0,
pointerEvents: "none",
...style,
}}
>
<WebglPortal />
<Effects />
</Canvas>
{children}
</div>
)
}
Add 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>;
}Pass your images as an items array. They spread evenly around each ring. Each item is a src and alt.
const ITEMS = [
{ src: "/a.jpg", alt: "Project A" },
{ src: "/b.jpg", alt: "Project B" },
{ src: "/c.jpg", alt: "Project C" },
]
<OrbitGallery items={ITEMS} className="fixed inset-0" />The size comes from the element you render it into. Use className to decide its size.
{/* Fills the viewport */}
<OrbitGallery items={ITEMS} className="fixed inset-0 w-screen h-screen" />
{/* Sits in a sized container */}
<OrbitGallery items={ITEMS} className="h-[500px] w-[500px]" />Orbit Gallery renders through a WebGL Scene. By default that scene draws above other content. Set mode="texture" to render it into post-processing instead, so effects like FluidDistortion apply to the ring.
<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. |
| 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. |
| 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. |
| className | string | - | Classes for the interaction surface the gallery renders into. |
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.
Cosmos
Demo images sourced from the website. If you don't want your image shown in this demo, send me an email and I'll remove it.