Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Collage (01)

  • Fluid Scene

Components (27)

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

Lens Image

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

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

Settings

size
0.23
softness
0.38
aberration
0.170
dispersion
50
refraction
0.39
smoothing
10
webgl-enabled
See the documentation below for more options.

Install

npx atelier-ui add lens-image
npm install three @react-three/fiber @react-three/drei
lens-image.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"

declare module "@react-three/fiber" {
    interface ThreeElements {
        lensImageMat: ThreeElement<typeof LensImageMat>
    }
}

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 LensImageMat = 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({ LensImageMat })

type LensImageMaterialProps = {
    map: Texture
    pointer: Pointer
    size: number
    softness: number
    aberration: number
    refraction: number
    dispersion: number
    smoothing: number
}

export type LensImageProps = {
    src: string
    alt: string
    size?: number
    softness?: number
    aberration?: number
    refraction?: number
    dispersion?: number
    smoothing?: number
    segments?: number
    webglEnabled?: boolean
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">

function LensImageMaterial({
    map,
    pointer,
    size,
    softness,
    aberration,
    refraction,
    dispersion,
    smoothing,
}: LensImageMaterialProps) {
    const ref = useRef<InstanceType<typeof LensImageMat>>(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} />

            <lensImageMat
                ref={ref}
                key={LensImageMat.key}
                uMap={map}
                uSize={size}
                uSoftness={softness}
                uAberration={aberration}
                uRefraction={refraction}
                uDispersion={dispersion}
                transparent
            />
        </>
    )
}

export function LensImage({
    src,
    alt,
    size = 0.23,
    softness = 0.38,
    aberration = 0.17,
    refraction = 0.39,
    dispersion = 50,
    smoothing = 10,
    segments = 1,
    webglEnabled = true,
    ...rest
}: LensImageProps) {
    return (
        <WebglImage
            src={src}
            alt={alt}
            segments={segments}
            webglEnabled={webglEnabled}
            material={(map, pointer) => (
                <LensImageMaterial
                    map={map}
                    pointer={pointer}
                    size={size}
                    softness={softness}
                    aberration={aberration}
                    refraction={refraction}
                    dispersion={dispersion}
                    smoothing={smoothing}
                />
            )}
            {...rest}
        />
    )
}
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
    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>
            )}
        </>
    )
}

API

NameTypeDefaultDescription
srcstring—Image source.
altstring—Image alt text.
sizenumber0.23Lens radius.
softnessnumber0.38Lens edge falloff.
aberrationnumber0.17Color spread width.
dispersionnumber50Color sample count.
refractionnumber0.39Inward pull strength.
smoothingnumber10Cursor damping speed.
segmentsnumber1Plane geometry subdivisions.
webglEnabledbooleantrueToggle WebGL effect on/off.
...propsImgHTMLAttributes—Other <img> attributes forwarded to the underlying element.

Credits

React Three Fiber
React renderer for Three.js.

Drei
React Three Fiber utilities.

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