An SEO-friendly WebGL image or video with a cursor-driven liquid ripple effect.
npx atelier-ui add liquid-medianpm install three @react-three/fiber @react-three/dreiimport { 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}
/>
)
}
import { useTexture } from "@react-three/drei"
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { type Mesh, type Texture, Vector2 } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
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 size = useThree((s) => s.size)
const viewport = useThree((s) => s.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const target = el.current
if (!target) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds 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 (UVs alone can't letterbox).
*/
const image = texture.image as HTMLImageElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const imageAspect = image.width / image.height
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
if (objectFit === "cover") {
if (planeAspect > imageAspect) {
repeatV = imageAspect / planeAspect
} else {
repeatU = planeAspect / imageAspect
}
} else if (objectFit === "contain") {
if (planeAspect > imageAspect) {
fitScale.current.x = imageAspect / planeAspect
} else {
fitScale.current.y = planeAspect / imageAspect
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
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()
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => ro.disconnect()
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like 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
})
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 = 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 (!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 = (e: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (e.clientX - left) / width
const y = 1 - (e.clientY - top) / height
const fit = uvFit.current
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)
}
}, [webglEnabled, pointer])
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>
)}
</>
)
}
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
texUv: Vector2
repeat: 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
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 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 so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds 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 (UVs alone can't letterbox).
*/
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, so 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
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
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, uvFit, pointer])
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 like 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 uvFit = useRef({ x: 1, y: 1 })
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 (!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
const fit = uvFit.current
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)
}
}, [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}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
## Integrate the <LiquidMedia /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: LiquidMedia
### Description: An SEO-friendly WebGL image or video with a cursor-driven liquid ripple effect.
### Dependencies: three, @react-three/fiber, @react-three/drei, @react-three/postprocessing, postprocessing, motion
---
### Usage Example
Add the `WebglProvider` once at the root of your app. See the [installation guide](https://atelier-ui.com/docs/getting-started/installation) for details.
```tsx title="Root layout"
import { WebglProvider } from "@/components/webgl-provider";
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>;
}
```
Then use it anywhere in your app:
```tsx
<LiquidMedia
type="image"
src="https://picsum.photos/seed/atelier-1/1200/800"
alt=""
className="w-full h-auto"
/>
```
---
### Props
| Name | Type | Default | Description |
| ------------ | -------------------- | ------------ | -------------------------------------------------------- |
| `type` | `"image" \| "video"` | `"image"` | Render the ripple on a still image or a video. |
| `src` | `string` | — | Media source. |
| `alt` | `string` | — | Image alt text. Required (and only used) when `type` is `"image"`. |
| `rippleMap` | `Texture` | built-in | Custom ripple texture. |
| `intensity` | `number` | `0.2` | Displacement strength. |
| `radius` | `number` | `12` | Ripple size, up to `50`. |
| `expandRate` | `number` | `11` | How fast ripples grow, up to `20`. |
| `decayRate` | `number` | `3` | How fast ripples fade out, between `3` and `10`. |
| `segments` | `number` | `1` | Plane geometry subdivisions. |
| `webglEnabled` | `boolean` | `true` | Toggle WebGL effect on/off. |
It also forwards `<img>` attributes to [WebGL Image](https://atelier-ui.com/docs/components/primitive/webgl-image) and `<video>` attributes to [WebGL Video](https://atelier-ui.com/docs/components/primitive/webgl-video) depending on `type`.
---
### Full Component Source
#### src/components/liquid-media/liquid-media.tsx
```tsx
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}
/>
)
}
```
#### src/components/webgl-image/webgl-image.tsx
```tsx
import { useTexture } from "@react-three/drei"
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { type Mesh, type Texture, Vector2 } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
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 size = useThree((s) => s.size)
const viewport = useThree((s) => s.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const target = el.current
if (!target) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds 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 (UVs alone can't letterbox).
*/
const image = texture.image as HTMLImageElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const imageAspect = image.width / image.height
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
if (objectFit === "cover") {
if (planeAspect > imageAspect) {
repeatV = imageAspect / planeAspect
} else {
repeatU = planeAspect / imageAspect
}
} else if (objectFit === "contain") {
if (planeAspect > imageAspect) {
fitScale.current.x = imageAspect / planeAspect
} else {
fitScale.current.y = planeAspect / imageAspect
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
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()
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => ro.disconnect()
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like 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
})
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 = 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 (!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 = (e: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (e.clientX - left) / width
const y = 1 - (e.clientY - top) / height
const fit = uvFit.current
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)
}
}, [webglEnabled, pointer])
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>
)}
</>
)
}
```
#### src/components/webgl-video/webgl-video.tsx
```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
texUv: Vector2
repeat: 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
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 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 so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds 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 (UVs alone can't letterbox).
*/
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, so 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
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
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, uvFit, pointer])
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 like 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 uvFit = useRef({ x: 1, y: 1 })
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 (!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
const fit = uvFit.current
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)
}
}, [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}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
```
#### 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, 1.5]}
{...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 liquid-media` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: three, @react-three/fiber, @react-three/drei, @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 `<LiquidMedia />` 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/cursor/liquid-mediaAdd the WebglProvider once at the root of your app. See the installation guide for details.
import { WebglProvider } from "@/components/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"
/>| Name | Type | Default | Description |
|---|---|---|---|
type | "image" | "video" | "image" | Render the ripple on a still image or a video. |
src | string | — | Media source. |
alt | string | — | Image alt text. Required (and only used) when type is "image". |
rippleMap | Texture | built-in | Custom ripple texture. |
intensity | number | 0.2 | Displacement strength. |
radius | number | 12 | Ripple size, up to 50. |
expandRate | number | 11 | How fast ripples grow, up to 20. |
decayRate | number | 3 | How fast ripples fade out, between 3 and 10. |
segments | number | 1 | Plane geometry subdivisions. |
webglEnabled | boolean | true | Toggle WebGL effect on/off. |
It also forwards <img> attributes to WebGL Image and <video> attributes to WebGL Video depending on type.
14islands
Re-creation of the ripple effect from their website.
homunculus Inc.
Original ripple effect from their portfolio website.
Yuri Artiukh
Deconstructed the effect on his stream.
React Three Fiber
React renderer for Three.js.
Drei
React Three Fiber utilities.