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 draws onto this canvas, so the application holds one WebGL context instead of one per effect.
Add the provider once, at your application root. For installation steps, see the installation guide.
npx atelier-ui add webgl-providernpm install three @react-three/fiber @react-three/postprocessing postprocessing"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>
)
}
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"
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"
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.
| 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.