An SEO-friendly WebGL image or video with a cursor-following circular lens and chromatic aberration
npx atelier-ui add lens-medianpm install three @react-three/fiber @react-three/dreiimport { shaderMaterial } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import { useRef } from "react"
import { type Group, MathUtils, Texture, Vector2 } from "three"
import { type Pointer, WebglImage } from "../webgl-image/webgl-image"
import { WebglVideo } from "../webgl-video/webgl-video"
declare module "@react-three/fiber" {
interface ThreeElements {
lensMediaMat: ThreeElement<typeof LensMediaMat>
}
}
const vertexShader = /* glsl */ `
precision highp float;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = /* glsl */ `
precision highp float;
#define MAX_DISPERSION 64
varying vec2 vUv;
uniform sampler2D uMap;
uniform vec2 uMouse;
uniform float uAspect;
uniform float uSize;
uniform float uSoftness;
uniform float uAberration;
uniform float uRefraction;
uniform float uHover;
uniform int uDispersion;
void main() {
vec2 toCenter = vUv - uMouse;
vec2 aspectDistance = vec2(toCenter.x * uAspect, toCenter.y);
float radius = length(aspectDistance);
float mask = (1.0 - smoothstep(uSize, uSize + uSoftness, radius)) * uHover;
vec2 refracted = vUv - toCenter * uRefraction * mask;
vec2 shift = toCenter * uAberration * mask;
vec3 color = vec3(0.0);
vec3 total = vec3(0.0);
for (int i = 0; i < MAX_DISPERSION; i++) {
if (i >= uDispersion) break;
float t = (float(i) + 0.5) / float(uDispersion);
vec3 weight = clamp(1.0 - 2.0 * abs(t - vec3(0.0, 0.5, 1.0)), 0.0, 1.0);
color += texture2D(uMap, refracted + shift * (1.0 - 2.0 * t)).rgb * weight;
total += weight;
}
gl_FragColor = vec4(color / max(total, vec3(0.0001)), 1.0);
}
`
const LensMediaMat = shaderMaterial(
{
uMap: new Texture(),
uMouse: new Vector2(0.5, 0.5),
uAspect: 1,
uSize: 0.23,
uSoftness: 0.38,
uAberration: 0.17,
uRefraction: 0.39,
uHover: 0,
uDispersion: 50,
},
vertexShader,
fragmentShader,
)
extend({ LensMediaMat })
type LensMediaMaterialProps = {
map: Texture
pointer: Pointer
size: number
softness: number
aberration: number
refraction: number
dispersion: number
smoothing: number
}
// Effect props shared by both the image and video variants.
export type LensEffectProps = {
size?: number
softness?: number
aberration?: number
refraction?: number
dispersion?: number
smoothing?: number
segments?: number
webglEnabled?: boolean
}
type LensMediaImageProps = LensEffectProps & {
type?: "image"
src: string
alt: string
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">
type LensMediaVideoProps = LensEffectProps & {
type: "video"
src: string
} & Omit<React.ComponentPropsWithoutRef<"video">, "src">
export type LensMediaProps = LensMediaImageProps | LensMediaVideoProps
function LensMediaMaterial({
map,
pointer,
size,
softness,
aberration,
refraction,
dispersion,
smoothing,
}: LensMediaMaterialProps) {
const ref = useRef<InstanceType<typeof LensMediaMat>>(null)
const anchorRef = useRef<Group>(null)
useFrame((_, delta) => {
const material = ref.current
if (!material) return
const mouse = material.uMouse
mouse.x = MathUtils.damp(mouse.x, pointer.uv.x, smoothing, delta)
mouse.y = MathUtils.damp(mouse.y, pointer.uv.y, smoothing, delta)
material.uHover = MathUtils.damp(material.uHover, pointer.hover, smoothing, delta)
const parent = anchorRef.current?.parent
if (parent) material.uAspect = parent.scale.x / parent.scale.y
})
return (
<>
<group ref={anchorRef} />
<lensMediaMat
ref={ref}
key={LensMediaMat.key}
uMap={map}
uSize={size}
uSoftness={softness}
uAberration={aberration}
uRefraction={refraction}
uDispersion={dispersion}
transparent
/>
</>
)
}
export function LensMedia(props: LensMediaProps) {
const {
size = 0.23,
softness = 0.38,
aberration = 0.17,
refraction = 0.39,
dispersion = 50,
smoothing = 10,
segments = 1,
webglEnabled = true,
...rest
} = props
// The lens is a fragment-only shader, so the same material runs on both
// the image and video primitives (they share the WebGL plane contract).
const material = (map: Texture, pointer: Pointer) => (
<LensMediaMaterial
map={map}
pointer={pointer}
size={size}
softness={softness}
aberration={aberration}
refraction={refraction}
dispersion={dispersion}
smoothing={smoothing}
/>
)
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
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
zIndex: number
autoReflow: boolean
}
function Plane({ el, src, segments, material, pointer, 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 (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 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
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])
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 (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
})
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 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 = (e: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (e.clientX - left) / width
const y = 1 - (e.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 (
<>
<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}
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
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>
)}
</>
)
}
| Name | Type | Default | Description |
|---|---|---|---|
| type | "image" | "video" | "image" | Render the lens on a still image or a video. |
| src | string | — | Media source. |
| alt | string | — | Image alt text. Required (and only used) when type is "image". |
| size | number | 0.23 | Lens radius. |
| softness | number | 0.38 | Lens edge falloff. |
| aberration | number | 0.17 | Color spread width. |
| dispersion | number | 50 | Color sample count. |
| refraction | number | 0.39 | Inward pull strength. |
| smoothing | number | 10 | Cursor damping speed. |
| segments | number | 1 | Plane geometry subdivisions. |
| webglEnabled | boolean | true | Toggle WebGL effect on/off. |
| ...props | ImgHTMLAttributes | VideoHTMLAttributes | — | Other <img> or <video> attributes forwarded to the underlying element. |
React Three Fiber
React renderer for Three.js.
Drei
React Three Fiber utilities.