Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral 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

Foundation Blocks (07)

  • 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. Liquid Media

Liquid Media

An SEO-friendly WebGL image or video with a cursor-driven liquid ripple effect.

https://atelier-ui.com/liquid-media
type
intensity
0.20
radius
12
expand-rate
11
decay-rate
3
webgl-enabled
type
intensity
0.20
radius
12
expand-rate
11
decay-rate
3
webgl-enabled
Prompt
Add Atelier's Liquid Media to my app.

If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/liquid-media
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.

Props: type="image" intensity={0.2} radius={12} expandRate={11} decayRate={3} webglEnabled={true}

Just into your favorite coding agent and let Atelier do its magic. Live props are included.


This command will install all the dependencies this component uses.

npx shadcn@latest add @atelier/liquid-media

Install 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/drei
liquid-media.tsx
"use client"

import { useFBO, useTexture } from "@react-three/drei"
import { createPortal, useFrame, useThree } from "@react-three/fiber"
import { useEffect, useMemo, useRef } from "react"
import type { Group, Mesh, MeshBasicMaterial, PlaneGeometry, ShaderMaterial, Texture } from "three"
import { AdditiveBlending, LinearFilter, MathUtils, OrthographicCamera, Scene } from "three"
import { type Pointer, WebglImage } from "../webgl-image/webgl-image"
import { WebglVideo } from "../webgl-video/webgl-video"

const ROTATION_SPEED = 0.1
const INITIAL_OPACITY = 0.22
const DISPLACEMENT_DAMPING = 6.3
const VELOCITY_DAMPING = 6.3
const IDLE_VELOCITY_DAMPING = 0.3
const SPAWN_SPACING = 0.2
const MIN_SPAWN_INTERVAL = 1 / 60
const MIN_SPAWN_DISTANCE = 0.005
const MIN_VISIBLE_OPACITY = 0.002

type Splat = Mesh<PlaneGeometry, MeshBasicMaterial>

const RIPPLE_BRUSH =
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAnFBMVEUAAAD////////////////////////////////////////////////////+/v7////9/f3////9/f39/f3////////////9/f3+/v79/f3////////+/v79/f3+/v7////////+/v7////+/v7////+/v7+/v7////////////+/v7////+/v7+/v7+/v7+/v7////////////////Clg1EAAAANHRSTlMACAwRBBUcICQZKDM4LEAwSjxGTml2XEOCUmRhh1eLcXpUbpl+Wo+1kqumsJafnKK+ucPKHu8hDAAADa9JREFUeNrslcl6olAUhJOWCCKDgDJeJkEEFBzy/u/Wda7na9PboK6sRRbJon7qVN18vPXWW2/9UspNH68Wm3/e9XoIcv9zFzG8EkJRyH42m31BM4gYXgjB9l+LxVxqISkY4gUM5I9vn6uqpum6rmmqCghmeAGC/HzYa7ppL0m2aeqaykGA4ZkI/PmzxVzT7aVhWUEQWJZjAAIMKjM8sZMc/0LVzaUTuJ4XRZHnrdzAMgwwAAIMHANTPMOfPt+wXC/1kySOE39XRh4zSAQeBpfysfbsv3Rcr/TjbU7arteJn0bIgW7BdfgxT+Wx54e/Cf9oF283RR1CdbEBRLJLcQvrXx1AwY14FMInn1/6l/46L0LRNG3bNFkYgmEdyxhwCbkLnucNQXnQ+rh+FvlvatFU4zgM41gdGxHWhJDsZBscAxTLWxZAeAiBwuvXTcNalcl6E2bH8dR3UH8axqrNgLBFCrvSW61cl/dpm5q6YIKp/jOuP/x96T/03WEPHQ5gGCmFAghIYZemZVqWkeyErTPBg+bH/jX5H/aXy/l8uYCh6zkE2sQ6juWPxC+9wFkSwWSA//3zWrRj35E/BAQQUAYZLeKmoqBtxH7kWiD4mkiAAPD8qtI/8mP0vzkOHQI4XyEQUAQDIhAYZU3bFBkk0IokXTm2PieCCQB8AOq/R/sPRVv1EuB88+cbNEQghGjaIwYyHltR53HpGrY27QgKv/94/j0MIC9oAQTwswOD9CdlbTXQPuh3WZ0nUWDQEfAeTQ3AsPgBDMlDEtxXQAcQMvxG9nNPXH2VFVt/5XAEEwOwjQANlAAwGQf6SJh3PduHNZpX42/Vqduf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"

const vertexShader = /* glsl */ `
varying vec2 vUv;
varying vec2 vScreenUv;

void main() {
    vUv = uv;
    vec4 pos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    vScreenUv = pos.xy / pos.w * 0.5 + 0.5;
    gl_Position = pos;
}`

const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uTexture;
uniform sampler2D uDisplacement;
uniform float uDisplacementIntensity;
varying vec2 vUv;
varying vec2 vScreenUv;

#define PI 3.14159265

void main() {
    vec4 displacement = texture2D(uDisplacement, vScreenUv);
    float theta = displacement.r * 2.0 * PI;
    vec2 direction = vec2(sin(theta), cos(theta));
    vec2 displacedUv = vUv + direction * displacement.r * uDisplacementIntensity;
    vec4 color = texture2D(uTexture, displacedUv);
    gl_FragColor = color;
}`

type LiquidMediaMaterialProps = {
    map: Texture
    pointer: Pointer
} & Pick<LiquidEffectProps, "rippleMap" | "intensity" | "radius" | "expandRate" | "decayRate">

type Uniforms = {
    uTexture: { value: Texture | null }
    uDisplacement: { value: Texture | null }
    uDisplacementIntensity: { value: number }
}

export type LiquidEffectProps = {
    rippleMap?: Texture
    intensity?: number
    radius?: number
    expandRate?: number
    decayRate?: number
    segments?: number
    webglEnabled?: boolean
}

type LiquidMediaImageProps = LiquidEffectProps & {
    type?: "image"
    src: string
    alt: string
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">

type LiquidMediaVideoProps = LiquidEffectProps & {
    type: "video"
    src: string
} & Omit<React.ComponentPropsWithoutRef<"video">, "src">

export type LiquidMediaProps = LiquidMediaImageProps | LiquidMediaVideoProps

function LiquidMediaMaterial({
    map,
    pointer,
    rippleMap,
    intensity = 0.2,
    radius = 12,
    expandRate = 11,
    decayRate = 3,
}: LiquidMediaMaterialProps) {
    const { viewport, size, gl } = useThree()

    const defaultBrush = useTexture(RIPPLE_BRUSH)
    const brush = rippleMap ?? defaultBrush
    const anchorRef = useRef<Group>(null)
    const splatIndex = useRef(0)
    const spriteRefs = useRef<Splat[]>([])
    const spriteScene = useMemo(() => new Scene(), [])
    const spriteCamera = useMemo(() => new OrthographicCamera(-1, 1, 1, -1, 0, 1), [])
    const materialRef = useRef<ShaderMaterial>(null)
    const mouse = useRef({
        x: 0,
        y: 0,
        velocity: 0,
        spawnX: Infinity,
        spawnY: Infinity,
        spawnElapsed: Infinity,
    })

    const uniforms = useMemo<Uniforms>(
        () => ({
            uTexture: { value: map },
            uDisplacement: { value: null },
            uDisplacementIntensity: { value: 0 },
        }),
        [map],
    )

    const FBO = useFBO(size.width, size.height, {
        minFilter: LinearFilter,
        magFilter: LinearFilter,
    })

    useEffect(() => {
        uniforms.uTexture.value = map
    }, [map, uniforms])

    useEffect(() => {
        spriteCamera.left = -viewport.width / 2
        spriteCamera.right = viewport.width / 2
        spriteCamera.top = viewport.height / 2
        spriteCamera.bottom = -viewport.height / 2
        spriteCamera.updateProjectionMatrix()
    }, [viewport, spriteCamera])

    useFrame((_, delta) => {
        const parent = anchorRef.current?.parent as Mesh | null
        const mat = materialRef.current
        if (!parent || !mat || delta <= 0) return

        const sprites = spriteRefs.current
        const _mouse = mouse.current
        const hovering = pointer.hover > 0.5

        const pointerX = parent.position.x + (pointer.uv.x - 0.5) * parent.scale.x
        const pointerY = parent.position.y + (pointer.uv.y - 0.5) * parent.scale.y

        const dx = pointerX - _mouse.x
        const dy = pointerY - _mouse.y

        // Normalized to 60Hz so speed remains identical on every display.
        const speed = Math.sqrt(dx * dx + dy * dy) / (delta * 60)

        _mouse.x = pointerX
        _mouse.y = pointerY

        if (hovering) {
            _mouse.velocity = MathUtils.damp(_mouse.velocity, speed, VELOCITY_DAMPING, delta)
        } else {
            _mouse.velocity = MathUtils.damp(_mouse.velocity, 0, IDLE_VELOCITY_DAMPING, delta)
        }

        const scale = (radius * Math.min(viewport.width, viewport.height)) / 100
        const spacing = Math.max(scale * SPAWN_SPACING, MIN_SPAWN_DISTANCE)
        const spawnDx = pointerX - _mouse.spawnX
        const spawnDy = pointerY - _mouse.spawnY
        const spawnDist = Math.sqrt(spawnDx * spawnDx + spawnDy * spawnDy)

        _mouse.spawnElapsed += delta

        if (hovering && spawnDist > spacing && _mouse.spawnElapsed > MIN_SPAWN_INTERVAL) {
            const sprite = sprites[splatIndex.current]

            if (sprite) {
                sprite.visible = true
                sprite.position.set(pointerX, pointerY, 0)
                sprite.scale.set(scale, scale, 1)
                sprite.material.opacity = INITIAL_OPACITY
            }

            splatIndex.current = (splatIndex.current + 1) % 100
            _mouse.spawnX = pointerX
            _mouse.spawnY = pointerY
            _mouse.spawnElapsed = 0
        }

        for (const sprite of sprites) {
            if (sprite.visible) {
                sprite.rotation.z += 2 * delta * ROTATION_SPEED
                sprite.material.opacity = MathUtils.damp(
                    sprite.material.opacity,
                    0,
                    MathUtils.clamp(decayRate, 3, 10),
                    delta,
                )
                sprite.scale.x += delta * expandRate * scale
                sprite.scale.y = sprite.scale.x
                if (sprite.material.opacity < MIN_VISIBLE_OPACITY) sprite.visible = false
            }
        }

        gl.setRenderTarget(FBO)
        gl.render(spriteScene, spriteCamera)
        gl.setRenderTarget(null)

        mat.uniforms.uDisplacement.value = FBO.texture
        mat.uniforms.uDisplacementIntensity.value = MathUtils.damp(
            mat.uniforms.uDisplacementIntensity.value,
            intensity * _mouse.velocity * 5,
            DISPLACEMENT_DAMPING,
            delta,
        )
    })

    const portal = useMemo(
        () =>
            createPortal(
                <group>
                    {Array.from({ length: 100 }, (_, i) => (
                        <mesh
                            key={i}
                            ref={(mesh) => {
                                if (mesh) spriteRefs.current[i] = mesh as Splat
                            }}
                            visible={false}
                            rotation-z={Math.random() * Math.PI * 2}
                        >
                            <planeGeometry args={[1, 1]} />
                            <meshBasicMaterial
                                map={brush}
                                transparent
                                blending={AdditiveBlending}
                                depthTest={false}
                                depthWrite={false}
                            />
                        </mesh>
                    ))}
                </group>,
                spriteScene,
            ),
        [brush, spriteScene],
    )

    return (
        <>
            <group ref={anchorRef} />

            <shaderMaterial
                ref={materialRef}
                attach="material"
                vertexShader={vertexShader}
                fragmentShader={fragmentShader}
                uniforms={uniforms}
            />

            {portal}
        </>
    )
}

export function LiquidMedia(props: LiquidMediaProps) {
    const { rippleMap, intensity, radius, expandRate, decayRate, segments, webglEnabled, ...rest } =
        props

    const material = (map: Texture, pointer: Pointer) => (
        <LiquidMediaMaterial
            map={map}
            pointer={pointer}
            rippleMap={rippleMap}
            intensity={intensity}
            radius={radius}
            expandRate={expandRate}
            decayRate={decayRate}
        />
    )

    if (rest.type === "video") {
        const { type: _type, ...videoProps } = rest
        return (
            <WebglVideo
                segments={segments}
                webglEnabled={webglEnabled}
                material={material}
                {...videoProps}
            />
        )
    }

    const { type: _type, ...imageProps } = rest
    return (
        <WebglImage
            segments={segments}
            webglEnabled={webglEnabled}
            material={material}
            {...imageProps}
        />
    )
}
use-pointer-uv.ts
import { type RefObject, useEffect, useMemo } from "react"
import { Vector2 } from "three"

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

type UsePointerUvOptions = {
    enabled: boolean
    /**
     * Maps element UVs into cropped texture UVs when object-fit trims the
     * media. Defaults to identity, so `texUv` mirrors `uv`.
     */
    uvFit?: RefObject<{ x: number; y: number }>
    getRect?: (el: HTMLElement) => DOMRect
}

/**
 * Tracks the cursor over a DOM element as normalized UVs, mutated in place so
 * shader materials can read it every frame without re-rendering React.
 */
export function usePointerUv(
    el: RefObject<HTMLElement | null>,
    { enabled, uvFit, getRect }: UsePointerUvOptions,
): Pointer {
    const pointer = useMemo<Pointer>(() => {
        return {
            uv: new Vector2(0.5, 0.5),
            texUv: new Vector2(0.5, 0.5),
            repeat: new Vector2(1, 1),
            hover: 0,
        }
    }, [])

    useEffect(() => {
        if (!enabled) 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 rect = getRect ? getRect(target) : target.getBoundingClientRect()
            const x = (event.clientX - rect.left) / rect.width
            const y = 1 - (event.clientY - rect.top) / rect.height
            const fit = uvFit?.current ?? { x: 1, y: 1 }
            pointer.uv.set(x, y)
            pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
        }

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

        target.addEventListener("pointermove", onMove)
        target.addEventListener("pointerenter", onEnter)
        target.addEventListener("pointerleave", onLeave)

        /*
         * Hover in too fast and pointerenter fires before these listeners
         * attach, so seed hover from the live :hover state instead.
         */
        if (target.matches(":hover")) pointer.hover = 1

        return () => {
            target.removeEventListener("pointermove", onMove)
            target.removeEventListener("pointerenter", onEnter)
            target.removeEventListener("pointerleave", onLeave)
        }
    }, [enabled, el, pointer, uvFit, getRect])

    return pointer
}
use-dom-plane.ts
import { useFrame, useThree } from "@react-three/fiber"
import { type RefObject, useCallback, useRef } from "react"
import type { Mesh } from "three"

type UseDomPlaneOptions = {
    /**
     * Re-measures the DOM rect every frame so the plane follows animated
     * parents (motion, parallax). Costs one layout read per frame.
     */
    autoReflow: boolean
    fitScale?: RefObject<{ x: number; y: number }>
    getRect?: (el: HTMLElement) => DOMRect
}

/**
 * Positions and scales a mesh to cover a DOM element on the shared canvas.
 * Scroll is applied every frame. Layout changes are not observed here: the
 * caller calls `measureBounds` when the element resizes or repaints.
 */
export function useDomPlane(
    el: RefObject<HTMLElement | null>,
    mesh: RefObject<Mesh | null>,
    { autoReflow, fitScale, getRect }: UseDomPlaneOptions,
) {
    const size = useThree((state) => state.size)
    const viewport = useThree((state) => state.viewport)
    const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })

    const measureBounds = useCallback(() => {
        const target = el.current
        if (!target) return null

        /*
         * Rect in document coords so viewport position later needs only
         * window.scrollX/Y, instead of re-measuring bounds every render.
         */
        const rect = getRect ? getRect(target) : 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
        return rect
    }, [el, getRect])

    useFrame(() => {
        const m = mesh.current
        if (!m) return
        const pxToWorld = viewport.height / size.height
        const fit = fitScale?.current ?? { x: 1, y: 1 }

        const transitioning = document.documentElement.hasAttribute("data-atelier-transitioning")

        if ((autoReflow || transitioning) && el.current) {
            const rect = getRect ? getRect(el.current) : 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 * fit.x
            m.scale.y = rect.height * pxToWorld * fit.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 * fit.x
        m.scale.y = height * pxToWorld * fit.y
    })

    return measureBounds
}
object-fit.ts
import type { BufferAttribute, InterleavedBufferAttribute } from "three"

export type ObjectFitCrop = {
    repeatU: number
    repeatV: number
    fitScaleX: number
    fitScaleY: number
}

/**
 * Replicates CSS object-fit on a WebGL plane: cover crops via UV repeat,
 * contain shrinks the mesh scale (UVs alone can't letterbox).
 *
 * Returns the neutral crop while the media aspect is unknown (e.g. a video
 * before its metadata loads reports 0x0).
 */
export function computeObjectFit(
    planeAspect: number,
    mediaAspect: number,
    objectFit: string,
): ObjectFitCrop {
    const crop: ObjectFitCrop = { repeatU: 1, repeatV: 1, fitScaleX: 1, fitScaleY: 1 }
    if (!Number.isFinite(mediaAspect) || mediaAspect <= 0) return crop

    if (objectFit === "cover") {
        if (planeAspect > mediaAspect) {
            crop.repeatV = mediaAspect / planeAspect
        } else {
            crop.repeatU = planeAspect / mediaAspect
        }
    } else if (objectFit === "contain") {
        if (planeAspect > mediaAspect) {
            crop.fitScaleX = mediaAspect / planeAspect
        } else {
            crop.fitScaleY = planeAspect / mediaAspect
        }
    }

    return crop
}

/** Rewrites a plane's UV grid so the texture samples the cropped region. */
export function applyUvCrop(
    uvAttribute: BufferAttribute | InterleavedBufferAttribute,
    segments: number,
    repeatU: number,
    repeatV: number,
) {
    const offsetU = (1 - repeatU) / 2
    const offsetV = (1 - repeatV) / 2

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

    uvAttribute.needsUpdate = true
}
webgl-image.tsx
"use client"

import { useTexture } from "@react-three/drei"
import { type ComponentRef, type RefObject, useLayoutEffect, useRef } from "react"
import type { Mesh, Texture } from "three"
import { useDomPlane } from "../../hooks/use-dom-plane"
import { type Pointer, usePointerUv } from "../../hooks/use-pointer-uv"
import { applyUvCrop, computeObjectFit } from "../../lib/object-fit"
import { webglTeleport } from "../webgl-portal/webgl-portal"

export type { Pointer }

type WebglImageProps = {
    src: string
    alt: 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<"img">, "children" | "src" | "alt">

type PlaneProps = {
    el: RefObject<HTMLImageElement | null>
    src: string
    segments: number
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    pointer: Pointer
    uvFit: RefObject<{ x: number; y: number }>
    zIndex: number
    autoReflow: boolean
}

function Plane({ el, src, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
    const mesh = useRef<Mesh>(null)
    const texture = useTexture(src)
    const fitScale = useRef({ x: 1, y: 1 })
    const measureBounds = useDomPlane(el, mesh, { autoReflow, fitScale })

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

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

            const image = texture.image as HTMLImageElement
            const crop = computeObjectFit(
                rect.width / rect.height,
                image.width / image.height,
                getComputedStyle(target).objectFit,
            )

            fitScale.current.x = crop.fitScaleX
            fitScale.current.y = crop.fitScaleY
            pointer.repeat.set(crop.repeatU, crop.repeatV)
            uvFit.current.x = crop.repeatU / crop.fitScaleX
            uvFit.current.y = crop.repeatV / crop.fitScaleY

            applyUvCrop(m.geometry.attributes.uv, segments, crop.repeatU, crop.repeatV)
        }

        measure()

        const ro = new ResizeObserver(measure)
        ro.observe(target)
        ro.observe(document.body)
        return () => ro.disconnect()
    }, [el, texture, segments, uvFit, pointer, measureBounds])

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

export function WebglImage({
    src,
    alt,
    className,
    style,
    material,
    webglEnabled = true,
    segments = 1,
    zIndex = 0,
    autoReflow = false,
    ...rest
}: WebglImageProps) {
    const el = useRef<ComponentRef<"img">>(null)
    const uvFit = useRef({ x: 1, y: 1 })
    const pointer = usePointerUv(el, { enabled: webglEnabled, uvFit })

    return (
        <>
            <img
                ref={el}
                src={src}
                alt={alt}
                className={className}
                style={webglEnabled ? { ...style, opacity: 0 } : style}
                {...rest}
            />

            {webglEnabled && (
                <webglTeleport.In>
                    <Plane
                        el={el}
                        src={src}
                        segments={segments}
                        material={material}
                        pointer={pointer}
                        uvFit={uvFit}
                        zIndex={zIndex}
                        autoReflow={autoReflow}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}
webgl-video.tsx
"use client"

import { useFrame } from "@react-three/fiber"
import { type ComponentRef, type RefObject, useLayoutEffect, useRef, useState } from "react"
import { type Mesh, SRGBColorSpace, type Texture, VideoTexture } from "three"
import { useDomPlane } from "../../hooks/use-dom-plane"
import { type Pointer, usePointerUv } from "../../hooks/use-pointer-uv"
import { applyUvCrop, computeObjectFit } from "../../lib/object-fit"
import { webglTeleport } from "../webgl-portal/webgl-portal"

export type { Pointer }

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
    uvFit: RefObject<{ x: number; y: number }>
    zIndex: number
    autoReflow: boolean
}

function Plane({ el, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
    const mesh = useRef<Mesh>(null)
    const [texture, setTexture] = useState<VideoTexture | null>(null)
    const fitScale = useRef({ x: 1, y: 1 })
    const measureBounds = useDomPlane(el, mesh, { autoReflow, fitScale })

    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
            const rect = measureBounds()
            if (!rect) return

            /*
             * videoWidth/Height are 0 until metadata loads, which yields an
             * invalid aspect, so computeObjectFit skips cropping until then.
             */
            const video = texture.image as HTMLVideoElement
            const crop = computeObjectFit(
                rect.width / rect.height,
                video.videoWidth / video.videoHeight,
                getComputedStyle(target).objectFit,
            )

            fitScale.current.x = crop.fitScaleX
            fitScale.current.y = crop.fitScaleY
            pointer.repeat.set(crop.repeatU, crop.repeatV)
            uvFit.current.x = crop.repeatU / crop.fitScaleX
            uvFit.current.y = crop.repeatV / crop.fitScaleY

            applyUvCrop(m.geometry.attributes.uv, segments, crop.repeatU, crop.repeatV)
        }

        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, uvFit, pointer, measureBounds])

    useFrame(() => {
        /* Browsers without requestVideoFrameCallback need an explicit pull. */
        texture?.update()
    })

    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 uvFit = useRef({ x: 1, y: 1 })
    const pointer = usePointerUv(el, { enabled: webglEnabled, uvFit })

    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}
                        uvFit={uvFit}
                        zIndex={zIndex}
                        autoReflow={autoReflow}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}
webgl-portal.tsx
"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 }
webgl-provider.tsx
"use client"

import { advance, Canvas, type CanvasProps, useFrame, 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 RootRender() {
    const gl = useThree((state) => state.gl)
    const scene = useThree((state) => state.scene)
    const camera = useThree((state) => state.camera)

    useFrame(() => gl.render(scene, camera), 1)

    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 <RootRender />

    return (
        <EffectComposer key={effects.length} multisampling={0}>
            <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, 1.8]}
                {...canvasProps}
                frameloop="never"
                eventSource={eventSource ?? undefined}
                style={{
                    position: contained ? "absolute" : "fixed",
                    inset: 0,
                    pointerEvents: "none",
                    ...style,
                }}
            >
                <MotionFrameloop />
                <WebglPortal />
                <Effects />
            </Canvas>

            {children}
        </div>
    )
}

All WebGL components render through one shared canvas. Add the WebglProvider once at the root of your app:

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

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

Then use it anywhere in your app:

<LiquidMedia
    type="image"
    src="https://picsum.photos/seed/atelier-1/1200/800"
    alt=""
    className="w-full h-auto"
/>

NameTypeDefaultDescription
type"image" | "video""image"Render the ripple on a still image or a video.
srcstring—Media source.
altstring—Image alt text. Required (and only used) when type is "image".
rippleMapTexturebuilt-inCustom ripple texture.
intensitynumber0.2Displacement strength.
radiusnumber12Ripple size, up to 50.
expandRatenumber11How fast ripples grow, up to 20.
decayRatenumber3How fast ripples fade out, between 3 and 10.
segmentsnumber1Plane geometry subdivisions.
webglEnabledbooleantrueToggle WebGL effect on/off.

It also forwards <img> attributes to WebGL Image and <video> attributes to WebGL Video depending on type.


React Three Fiber
React renderer for Three.js.

Motion
React animation library.

WebGL Image (Atelier)
A WebGL plane that mirrors an image.

WebGL Video (Atelier)
A WebGL plane that mirrors a video.

WebGL Provider (Atelier)
A single shared WebGL canvas for the whole app.

Star on githubBuy me a coffeellms.txt