Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • Installation
  • How to contribute

Components (45)

  • Orbit Gallery
  • Sphere Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll
  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Webgl Provider

WebGL Provider

The root that runs one shared WebGL canvas for the whole page.

Foundation Block
React Three Fiber

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.

The canvas draws 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 draws the canvas, so WebGL elements always stay exactly on top of the elements they follow.

Add the provider once, at your application root. For installation steps, see the installation guide.


Install

npx atelier-ui add webgl-provider
npm install three @react-three/fiber @react-three/postprocessing postprocessing motion
webgl-provider.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>
    )
}
webgl-portal.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 }
prompt.md
## Integrate the <WebGLProvider /> component from Atelier UI

You are helping integrate an open-source React component into an existing application.

### Component: WebGLProvider
### Description: The root that runs one shared WebGL canvas for the whole page.
### Dependencies: three, @react-three/fiber, @react-three/postprocessing, postprocessing, motion

---

### Usage Example

Wrap your root layout with the provider. The canvas renders fixed and full screen behind the page, with your content on top:

```tsx title="Root layout"
import { WebglProvider } from "@/components/webgl-provider"

export default function RootLayout({ children }) {
    return <WebglProvider>{children}</WebglProvider>
}
```

### Pass Canvas options

Any [React Three Fiber Canvas prop](https://r3f.docs.pmnd.rs/api/canvas) 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:

```tsx title="Custom Canvas options"
<WebglProvider dpr={[1, 2]} gl={{ antialias: false }}>
    {children}
</WebglProvider>
```

### Scope the canvas to its container

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:

```tsx title="Contained canvas"
<WebglProvider contained className="relative h-96 w-full">
    <Demo />
</WebglProvider>
```

### Track scene readiness

`useWebglReady` returns `false` until the scene's shaders have compiled. This can help prevent visible shader-compilation stutter when an effect first appears.

```tsx
import { useWebglReady } from "@/components/webgl-provider"

function Effect() {
    const ready = useWebglReady()

    return <mesh visible={ready}>{/* ... */}</mesh>
}
```

---

### Props

| 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](https://r3f.docs.pmnd.rs/api/canvas) are forwarded, except `children` and `eventSource`.
`frameloop` is always `"never"`, because Motion's frame loop advances the canvas.

### useWebglReady

| 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`.

---

### Full Component Source

#### 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 }
```

#### 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>
    )
}
```

---

### Integration Instructions

1. If you can execute shell commands, run `npx atelier-ui add webgl-provider` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: three, @react-three/fiber, @react-three/postprocessing, postprocessing, motion.
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 `<WebGLProvider />` 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/primitive/webgl-provider

Usage

Wrap your root layout with the provider. The canvas renders fixed and full screen behind the page, with your content on top:

Root layout
import { WebglProvider } from "@/components/webgl-provider"

export default function RootLayout({ children }) {
    return <WebglProvider>{children}</WebglProvider>
}

Pass Canvas options

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:

Custom Canvas options
<WebglProvider dpr={[1, 2]} gl={{ antialias: false }}>
    {children}
</WebglProvider>

Scope the canvas to its container

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:

Contained canvas
<WebglProvider contained className="relative h-96 w-full">
    <Demo />
</WebglProvider>

Track scene readiness

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>
}

API

NameTypeDefaultDescription
childrenReactNode—Your app or page content. Rendered as normal DOM next to the canvas. Required.
containedbooleanfalseScope the canvas to the provider's element instead of the viewport.
classNamestring—Class on the wrapper element.
styleCSSProperties—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.

useWebglReady

NameTypeDefaultDescription
sceneScenecurrent sceneScene to compile.
cameraCameracurrent cameraCamera to compile against.
enabledbooleantrueRun 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.

  • Install
  • Usage
  • Pass Canvas options
  • Scope the canvas to its container
  • Track scene readiness
  • API
  • useWebglReady
Star on githubBuy me a coffeellms.txt