The root that runs one shared WebGL canvas for the whole page.
WebGL Provider mounts a single <Canvas> for the whole page and renders your application inside it. Every other WebGL component renders into this canvas, so the application holds one WebGL context instead of one per effect.
The canvas runs on Motion's clock instead of its own requestAnimationFrame.
With Smooth Scroll on the page, every tick moves the scroll first, then the page, then renders the canvas, so WebGL elements always stay exactly on top of the elements they follow.
Add the provider once, at your application root. For the systems it belongs to, see the introduction.
Add Atelier's WebGL Provider to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add https://atelier-ui.com/r/webgl-provider.json
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.This command will install all the dependencies this component uses.
npx shadcn@latest add https://atelier-ui.com/r/webgl-provider.jsonInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install three @types/three @react-three/fiber @react-three/postprocessing postprocessing motion"use client"
import { advance, Canvas, type CanvasProps, useStore, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ComponentRef, type ReactNode, useEffect, useRef, useState } from "react"
import type { Camera, Scene } from "three"
import { effectTeleport, WebglPortal } from "../webgl-portal/webgl-portal"
type WebglProviderProps = Omit<CanvasProps, "children" | "eventSource"> & {
children: ReactNode
className?: string
contained?: boolean
}
type WebglReadyOptions = {
scene?: Scene
camera?: Camera
enabled?: boolean
onReady?: () => void
}
export function useWebglReady({ scene, camera, enabled = true, onReady }: WebglReadyOptions = {}) {
const [ready, setReady] = useState(false)
const gl = useThree((state) => state.gl)
const defaultScene = useThree((state) => state.scene)
const defaultCamera = useThree((state) => state.camera)
const onReadyRef = useRef(onReady)
onReadyRef.current = onReady
const targetScene = scene ?? defaultScene
const targetCamera = camera ?? defaultCamera
useEffect(() => {
if (!enabled) return
let active = true
gl.compileAsync(targetScene, targetCamera).then(() => {
if (!active) return
requestAnimationFrame(() => {
if (!active) return
setReady(true)
onReadyRef.current?.()
})
})
return () => {
active = false
}
}, [gl, targetScene, targetCamera, enabled])
return ready
}
// Renders in Motion's `postRender` phase, after Lenis and Motion have
// updated. One shared driver serves every mounted provider.
type CanvasStore = ReturnType<typeof useStore>
const canvasStores = new Set<CanvasStore>()
let clockStart: number | null = null
function tick(data: FrameData) {
if (clockStart === null) clockStart = data.timestamp
// frameloop="never" expects the elapsed clock time in seconds.
const elapsed = (data.timestamp - clockStart) / 1000
let runGlobalEffects = true
for (const store of canvasStores) {
const state = store.getState()
if (state.internal.active) {
advance(elapsed, runGlobalEffects, state)
runGlobalEffects = false
}
}
}
function MotionFrameloop() {
const store = useStore()
useEffect(() => {
canvasStores.add(store)
if (canvasStores.size === 1) frame.postRender(tick, true)
return () => {
canvasStores.delete(store)
if (canvasStores.size === 0) cancelFrame(tick)
}
}, [store])
return null
}
function Effects() {
const effects = effectTeleport.useItems()
const gl = useThree((state) => state.gl)
const mounted = effects.length > 0
// EffectComposer sets `renderer.autoClear = false` and never restores it;
// without this the canvas keeps its last frame once the composer unmounts.
useEffect(() => {
if (!mounted) return
return () => {
gl.autoClear = true
}
}, [mounted, gl])
if (!mounted) return null
return (
<EffectComposer key={effects.length}>
<effectTeleport.Out />
</EffectComposer>
)
}
export function WebglProvider({
children,
className,
style,
contained = false,
...canvasProps
}: WebglProviderProps) {
const [eventSource, setEventSource] = useState<ComponentRef<"div"> | null>(null)
return (
<div
ref={setEventSource}
data-atelier-webgl=""
className={className}
style={contained ? { position: "relative" } : { display: "contents" }}
>
<Canvas
eventPrefix="client"
dpr={[1, 2]}
{...canvasProps}
frameloop="never"
eventSource={eventSource ?? undefined}
style={{
position: contained ? "absolute" : "fixed",
inset: 0,
pointerEvents: "none",
...style,
}}
>
<MotionFrameloop />
<WebglPortal />
<Effects />
</Canvas>
{children}
</div>
)
}
"use client"
import {
type ReactNode,
Suspense,
useEffect,
useId,
useLayoutEffect,
useSyncExternalStore,
} from "react"
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
// Minimal teleport: <In> registers children in an external store,
// <Out> renders them — bridges across the Canvas React root the same
function WebglTeleport() {
const items = new Map<string, ReactNode>()
const listeners = new Set<() => void>()
let snapshot: [string, ReactNode][] = []
const emit = () => {
snapshot = Array.from(items.entries())
for (const listener of listeners) {
listener()
}
}
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
const getSnapshot = () => snapshot
function useItems() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
return {
In({ children }: { children: ReactNode }) {
const id = useId()
useIsoLayoutEffect(() => {
items.set(id, children)
emit()
return () => {
items.delete(id)
emit()
}
}, [id, children])
return null
},
useItems,
Out() {
const list = useItems()
return (
<>
{list.map(([id, node]) => (
<Suspense key={id} fallback={null}>
{node}
</Suspense>
))}
</>
)
},
}
}
const webglTeleport = WebglTeleport()
const effectTeleport = WebglTeleport()
export function WebglPortal() {
return <webglTeleport.Out />
}
export { effectTeleport, webglTeleport }
Wrap your root layout with the provider. The canvas renders fixed and full screen behind the page, with your content on top:
import { WebglProvider } from "@/components/webgl-provider/webgl-provider"
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>
}Any React Three Fiber Canvas prop passes through the provider, so you can set props such as gl, camera, and dpr. The provider defaults to eventPrefix="client" and dpr={[1, 1.5]}, which your props override:
<WebglProvider dpr={[1, 2]} gl={{ antialias: false }}>
{children}
</WebglProvider>By default, the canvas covers the viewport. Set contained to keep it inside the provider's own element, which must have its own size. The component previews use this to render an effect inside a card rather than across the screen:
<WebglProvider contained className="relative h-96 w-full">
<Demo />
</WebglProvider>useWebglReady returns false until the scene's shaders have compiled. This can help prevent visible shader-compilation stutter when an effect first appears.
import { useWebglReady } from "@/components/webgl-provider/webgl-provider"
function Effect() {
const ready = useWebglReady()
return <mesh visible={ready}>{/* ... */}</mesh>
}| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Your app or page content. Rendered as normal DOM next to the canvas. Required. |
contained | boolean | false | Scope the canvas to the provider's element instead of the viewport. |
className | string | — | Class on the wrapper element. |
style | CSSProperties | — | Merged into the canvas style. |
All other React Three Fiber Canvas props are forwarded, except children and eventSource.
frameloop is always "never", because Motion's frame loop advances the canvas.
| Name | Type | Default | Description |
|---|---|---|---|
scene | Scene | current scene | Scene to compile. |
camera | Camera | current camera | Camera to compile against. |
enabled | boolean | true | Run the compile step. Set false to skip it. |
onReady | () => void | — | Called once the scene is compiled and one frame has passed. |
Returns a boolean that is false until the scene is ready, then true.
React Three Fiber
React renderer for Three.js.
Motion
React animation library.