Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (28)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • WebGL Text
  • WebGL Video
Atelier UI 0.7.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Webgl Video

WebGL Video

A primitive that mirrors a video onto a WebGL plane while preserving the DOM element.

Primitive
React Three Fiber

A structural building block for composing your own WebGL video effects. It is the video counterpart of WebGL Image, so any material you write for one works on the other.

The video is rendered twice: as a real <video> element (hidden when WebGL is on), and as a VideoTexture on a plane that tracks the element's bounding box. The texture pulls each new frame from the same element, so only one video decodes.

Requires a <Canvas> with <WebglPortal /> at the root of your app, see Installation.


Install

npx atelier-ui add webgl-video
npm install three @react-three/fiber
webgl-video.tsx
import { useFrame, useThree } from "@react-three/fiber"
import {
    type ComponentRef,
    type RefObject,
    useEffect,
    useLayoutEffect,
    useMemo,
    useRef,
    useState,
} from "react"
import { type Mesh, SRGBColorSpace, type Texture, Vector2, VideoTexture } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"

export type Pointer = {
    uv: Vector2
    hover: number
}

type WebglVideoProps = {
    src: string
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    webglEnabled?: boolean
    segments?: number
    zIndex?: number
    /**
     * Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
     * Costs one layout read per frame, so only enable it when needed.
     */
    autoReflow?: boolean
} & Omit<React.ComponentPropsWithoutRef<"video">, "children" | "src">

type PlaneProps = {
    el: RefObject<HTMLVideoElement | null>
    segments: number
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    pointer: Pointer
    zIndex: number
    autoReflow: boolean
}

function Plane({ el, segments, material, pointer, zIndex, autoReflow }: PlaneProps) {
    const mesh = useRef<Mesh>(null)
    const [texture, setTexture] = useState<VideoTexture | null>(null)
    const size = useThree((state) => state.size)
    const viewport = useThree((state) => state.viewport)
    const fitScale = useRef({ x: 1, y: 1 })
    const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })

    useLayoutEffect(() => {
        const video = el.current
        if (!video) return

        // Build the texture from the DOM <video> itself so a single element
        // decodes once; VideoTexture pulls each new frame from it.
        const videoTexture = new VideoTexture(video)
        videoTexture.colorSpace = SRGBColorSpace
        setTexture(videoTexture)
        return () => videoTexture.dispose()
    }, [el])

    useLayoutEffect(() => {
        const target = el.current
        if (!target || !texture) return

        const measure = () => {
            const m = mesh.current
            if (!m) return

            // Rect in document coords (top/left offset by current scroll at measure time),
            // so we can later derive viewport position with just `window.scrollX/Y`,
            // instead of recalculating bounds on every render
            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

            // Replicate CSS object-fit: cover crops via UV repeat/offset; contain shrinks the mesh scale
            // because UVs alone can't letterbox: the plane would still fill the element.
            const video = texture.image as HTMLVideoElement
            const objectFit = getComputedStyle(target).objectFit
            const planeAspect = rect.width / rect.height
            const videoAspect = video.videoWidth / video.videoHeight

            let repeatU = 1
            let repeatV = 1

            fitScale.current.x = 1
            fitScale.current.y = 1

            // videoWidth/Height are 0 until metadata loads; skip cropping until then.
            if (video.videoWidth > 0) {
                if (objectFit === "cover") {
                    if (planeAspect > videoAspect) {
                        repeatV = videoAspect / planeAspect
                    } else {
                        repeatU = planeAspect / videoAspect
                    }
                } else if (objectFit === "contain") {
                    if (planeAspect > videoAspect) {
                        fitScale.current.x = videoAspect / planeAspect
                    } else {
                        fitScale.current.y = planeAspect / videoAspect
                    }
                }
            }

            const offsetU = (1 - repeatU) / 2
            const offsetV = (1 - repeatV) / 2

            const uvAttribute = m.geometry.attributes.uv

            for (let iy = 0; iy <= segments; iy++) {
                for (let ix = 0; ix <= segments; ix++) {
                    const idx = iy * (segments + 1) + ix
                    const u = ix / segments
                    const v = 1 - iy / segments
                    uvAttribute.setXY(idx, u * repeatU + offsetU, v * repeatV + offsetV)
                }
            }

            uvAttribute.needsUpdate = true
        }

        measure()

        // Re-measure once the video reports its intrinsic size.
        target.addEventListener("loadedmetadata", measure)
        target.addEventListener("resize", measure)

        const ro = new ResizeObserver(measure)
        ro.observe(target)
        ro.observe(document.body)
        return () => {
            ro.disconnect()
            target.removeEventListener("loadedmetadata", measure)
            target.removeEventListener("resize", measure)
        }
    }, [el, texture, segments])

    useFrame(() => {
        const m = mesh.current
        if (!m) return

        // Browsers without requestVideoFrameCallback need an explicit pull.
        texture?.update()

        const pxToWorld = viewport.height / size.height

        // autoReflow re-reads the rect each frame so the mesh follows parent
        // CSS transforms (e.g. parallax). One layout read per frame.
        if (autoReflow && el.current) {
            const rect = el.current.getBoundingClientRect()
            m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
            m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
            m.scale.x = rect.width * pxToWorld * fitScale.current.x
            m.scale.y = rect.height * pxToWorld * fitScale.current.y
            return
        }

        const { x, y, width, height } = bounds.current
        m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
        m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
        m.scale.x = width * pxToWorld * fitScale.current.x
        m.scale.y = height * pxToWorld * fitScale.current.y
    })

    if (!texture) return null

    return (
        <mesh ref={mesh} renderOrder={zIndex}>
            <planeGeometry args={[1, 1, segments, segments]} />
            {material ? (
                material(texture, pointer)
            ) : (
                <meshBasicMaterial map={texture} transparent />
            )}
        </mesh>
    )
}

export function WebglVideo({
    src,
    className,
    style,
    material,
    webglEnabled = true,
    segments = 1,
    zIndex = 0,
    autoReflow = false,
    autoPlay = true,
    muted = true,
    loop = true,
    playsInline = true,
    ...rest
}: WebglVideoProps) {
    const el = useRef<ComponentRef<"video">>(null)
    const pointer = useMemo<Pointer>(() => {
        return {
            uv: new Vector2(0.5, 0.5),
            hover: 0,
        }
    }, [])

    useEffect(() => {
        if (!webglEnabled) return
        const target = el.current
        if (!target) return

        // Pointer events still fire on the DOM element through opacity:0,
        // so the browser tells us when the cursor is over it.
        const onMove = (event: PointerEvent) => {
            const { width, left, top, height } = target.getBoundingClientRect()
            const x = (event.clientX - left) / width
            const y = 1 - (event.clientY - top) / height
            pointer.uv.set(x, y)
        }

        const onEnter = () => (pointer.hover = 1)
        const onLeave = () => (pointer.hover = 0)

        target.addEventListener("pointermove", onMove)
        target.addEventListener("pointerenter", onEnter)
        target.addEventListener("pointerleave", onLeave)
        return () => {
            target.removeEventListener("pointermove", onMove)
            target.removeEventListener("pointerenter", onEnter)
            target.removeEventListener("pointerleave", onLeave)
        }
    }, [webglEnabled, pointer])

    return (
        <>
            <video
                ref={el}
                src={src}
                className={className}
                style={webglEnabled ? { ...style, opacity: 0 } : style}
                autoPlay={autoPlay}
                muted={muted}
                loop={loop}
                playsInline={playsInline}
                {...rest}
            />

            {webglEnabled && (
                <webglTeleport.In>
                    <Plane
                        el={el}
                        segments={segments}
                        material={material}
                        pointer={pointer}
                        zIndex={zIndex}
                        autoReflow={autoReflow}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}
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 = (l: () => void) => {
        listeners.add(l)
        return () => {
            listeners.delete(l)
        }
    }
    const getSnapshot = () => snapshot

    return {
        In({ children }: { children: ReactNode }) {
            const id = useId()

            useIsoLayoutEffect(() => {
                items.set(id, children)
                emit()
                return () => {
                    items.delete(id)
                    emit()
                }
            }, [id, children])
            return null
        },
        Out() {
            const list = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
            return (
                <>
                    {list.map(([id, node]) => (
                        <Suspense key={id} fallback={null}>
                            {node}
                        </Suspense>
                    ))}
                </>
            )
        },
    }
}

const webglTeleport = WebglTeleport()

export function WebglPortal() {
    return <webglTeleport.Out />
}

export { webglTeleport }

Basic usage

With no material, the plane uses a meshBasicMaterial and looks identical to a plain <video>. The video autoplays muted and looped by default, so it plays without a user gesture:

Default material
<WebglVideo src="/clip.mp4" className="w-full h-auto" />

Object-fit

object-fit: cover and object-fit: contain are replicated on the plane. Set it the way you would on a normal <video>:

Cover and contain
<WebglVideo src="/clip.mp4" className="w-full h-64 object-cover" />

With material

material lets you provide your own R3F material. It receives the VideoTexture and a live pointer (UV + hover). Reuse the exact shader you wrote for a still image, for example to run the CRT Image effect on footage:

Custom shader material
<WebglVideo
  src="/clip.mp4"
  material={(map, pointer) => (
    <myShaderMaterial
      uMap={map}
      uPointer={pointer.uv}
      uHover={pointer.hover}
      transparent
    />
  )}
/>

The pointer object is mutated in place, so reading it inside a useFrame gives current values without re-renders.

Use segments to subdivide the plane when your shader displaces vertices. 1 is enough for fragment-only effects.

Toggling WebGL off

Set webglEnabled={false} to skip the canvas pass and fall back to the plain DOM <video>:

Graceful fallback
<WebglVideo src="/clip.mp4" webglEnabled={!prefersReducedMotion} />

API

NameTypeDefaultDescription
srcstring—Video source. Required.
material(map: Texture, pointer: Pointer) => ReactNode—Custom R3F material. Receives the video texture and a live pointer.
segmentsnumber1Plane geometry subdivisions. Increase for vertex-displacing shaders.
webglEnabledbooleantrueToggle the WebGL plane. When false, only the DOM video is rendered.
zIndexnumber0Render order within the WebGL portal.
autoReflowbooleanfalseRe-measures the DOM rect every frame.

Standard <video> attributes (className, style, poster, controls, ...) are accepted and forwarded to the DOM element. autoPlay, muted, loop and playsInline default to true and can be overridden.

Pointer

NameTypeDescription
uvVector2Normalized pointer position inside the element. (0, 0) is bottom-left, (1, 1) is top-right.
hovernumber1 while the pointer is over the element, 0 otherwise.
  • Install
  • Basic usage
  • Object-fit
  • With material
  • Toggling WebGL off
  • API
  • Pointer
Star on githubBuy me a coffeellms.txt