Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • Installation
  • How to contribute

Components (38)

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

Lens Media

An SEO-friendly WebGL image or video with a cursor-following circular lens and chromatic aberration.

React Three Fiber
Drei
https://atelier-ui.com/lens-media

Settings

type
size
0.12
softness
0.50
aberration
0.180
dispersion
35
refraction
0.40
smoothing
7
webgl-enabled
See the documentation below for more options.

Install

npx atelier-ui add lens-media
npm install three @react-three/fiber @react-three/drei
lens-media.tsx
import { 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.12,
        uSoftness: 0.5,
        uAberration: 0.18,
        uRefraction: 0.4,
        uHover: 0,
        uDispersion: 35,
    },
    vertexShader,
    fragmentShader,
)

extend({ LensMediaMat })

type LensMediaMaterialProps = {
    map: Texture
    pointer: Pointer
} & Required<Pick<LensEffectProps, "size" | "softness" | "aberration" | "refraction" | "dispersion" | "smoothing">>

// 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.texUv.x, smoothing, delta)
        mouse.y = MathUtils.damp(mouse.y, pointer.texUv.y, smoothing, delta)
        material.uHover = MathUtils.damp(material.uHover, pointer.hover, smoothing, delta)

        const parent = anchorRef.current?.parent
        if (parent) {
            const aspect = parent.scale.x / parent.scale.y
            material.uAspect = (aspect * pointer.repeat.y) / pointer.repeat.x
        }
    })

    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.12,
        softness = 0.5,
        aberration = 0.18,
        refraction = 0.4,
        dispersion = 35,
        smoothing = 7,
        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}
        />
    )
}
webgl-image.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>
            )}
        </>
    )
}
webgl-video.tsx
import { useFrame, useThree } from "@react-three/fiber"
import {
    type ComponentRef,
    type RefObject,
    useEffect,
    useLayoutEffect,
    useMemo,
    useRef,
    useState,
} from "react"
import { type Mesh, SRGBColorSpace, type Texture, Vector2, VideoTexture } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"

export type Pointer = {
    uv: Vector2
    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>
            )}
        </>
    )
}
prompt.md
## Integrate the <LensMedia /> component from Atelier UI

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

### Component: LensMedia
### Description: An SEO-friendly WebGL image or video with a cursor-following circular lens and chromatic aberration.
### 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
<LensMedia
    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 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.12`    | Lens radius.                                             |
| `softness`     | `number`             | `0.5`     | Lens edge falloff.                                       |
| `aberration`   | `number`             | `0.18`    | Color spread width.                                      |
| `dispersion`   | `number`             | `35`      | Color sample count.                                      |
| `refraction`   | `number`             | `0.4`     | Inward pull strength.                                    |
| `smoothing`    | `number`             | `7`       | Cursor damping speed.                                    |
| `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/lens-media/lens-media.tsx

```tsx
import { 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.12,
        uSoftness: 0.5,
        uAberration: 0.18,
        uRefraction: 0.4,
        uHover: 0,
        uDispersion: 35,
    },
    vertexShader,
    fragmentShader,
)

extend({ LensMediaMat })

type LensMediaMaterialProps = {
    map: Texture
    pointer: Pointer
} & Required<Pick<LensEffectProps, "size" | "softness" | "aberration" | "refraction" | "dispersion" | "smoothing">>

// 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.texUv.x, smoothing, delta)
        mouse.y = MathUtils.damp(mouse.y, pointer.texUv.y, smoothing, delta)
        material.uHover = MathUtils.damp(material.uHover, pointer.hover, smoothing, delta)

        const parent = anchorRef.current?.parent
        if (parent) {
            const aspect = parent.scale.x / parent.scale.y
            material.uAspect = (aspect * pointer.repeat.y) / pointer.repeat.x
        }
    })

    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.12,
        softness = 0.5,
        aberration = 0.18,
        refraction = 0.4,
        dispersion = 35,
        smoothing = 7,
        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}
        />
    )
}
```

#### 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()
    if (effects.length === 0) 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 lens-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 `<LensMedia />` 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/lens-media

Usage

Add the WebglProvider once at the root of your app. See the installation guide for details.

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

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

Then use it anywhere in your app:

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

API

NameTypeDefaultDescription
type"image" | "video""image"Render the lens on a still image or a video.
srcstring—Media source.
altstring—Image alt text. Required (and only used) when type is "image".
sizenumber0.12Lens radius.
softnessnumber0.5Lens edge falloff.
aberrationnumber0.18Color spread width.
dispersionnumber35Color sample count.
refractionnumber0.4Inward pull strength.
smoothingnumber7Cursor damping speed.
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.


Credits

React Three Fiber
React renderer for Three.js.

Drei
React Three Fiber utilities.

  • Install
  • Usage
  • API
  • Credits
Star on githubBuy me a coffeellms.txt