Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Components (22)

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

WebGL Image

A primitive that mirrors an image onto a WebGL plane while preserving accessibility.

Primitive
React Three Fiber

A structural building block for composing your own WebGL image effects, like Curve Image.

The image is rendered twice: as a real <img> for SEO and screen readers (hidden when WebGL is on), and as a Texture on a plane that tracks the element's bounding box. The two stay pixel-aligned, so the shader feels like it runs on the DOM.

Requires a <Canvas> with <WebglPortal /> at the root of your app, see Installation.


Install

npx atelier-ui add webgl-image
npm install three @react-three/fiber @react-three/drei
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
    segments?: number
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    webglEnabled?: 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
}

function Plane({ el, src, segments, material, pointer }: 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 { x, y, width, height } = bounds.current
        const pxToWorld = viewport.height / size.height

        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}>
            <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,
    ...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

        // Track the pointer on the dom element directly and passed to the material
        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}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}
webgl-portal.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 = (l: () => void) => {
        listeners.add(l)
        return () => {
            listeners.delete(l)
        }
    }
    const getSnapshot = () => snapshot

    return {
        In({ children }: { children: ReactNode }) {
            const id = useId()

            useIsoLayoutEffect(() => {
                items.set(id, children)
                emit()
                return () => {
                    items.delete(id)
                    emit()
                }
            }, [id, children])
            return null
        },
        Out() {
            const list = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
            return (
                <>
                    {list.map(([id, node]) => (
                        <Suspense key={id} fallback={null}>
                            {node}
                        </Suspense>
                    ))}
                </>
            )
        },
    }
}

const webglTeleport = WebglTeleport()

export function WebglPortal() {
    return <webglTeleport.Out />
}

export { webglTeleport }

Basic usage

With no material, the plane uses a meshBasicMaterial and looks identical to a plain <img>:

Default material
<WebglImage src="/photo.jpg" alt="A photo" className="w-full h-auto" />

Object-fit

object-fit: cover and object-fit: contain are replicated on the plane. Set it the way you would on a normal <img>:

Cover and contain
<WebglImage src="/photo.jpg" alt="A photo" className="w-full h-64 object-cover" />

With material

material lets you provide your own R3F material. It receives the loaded Texture and a live pointer (UV + hover):

Custom shader material
<WebglImage
    src="/photo.jpg"
    alt="A photo"
    segments={32}
    material={(map, pointer) => (
        <myShaderMaterial
            uMap={map}
            uPointer={pointer.uv}
            uHover={pointer.hover}
            transparent
        />
    )}
/>

The pointer object is mutated in place, so reading it inside a useFrame gives current values without re-renders.

Use segments to subdivide the plane when your shader displaces vertices. 1 is enough for fragment-only effects.

Toggling WebGL off

Set webglEnabled={false} to skip the canvas pass and fall back to the plain DOM <img>:

Graceful fallback
<WebglImage
    src="/photo.jpg"
    alt="A photo"
    webglEnabled={!prefersReducedMotion}
/>

API

NameTypeDefaultDescription
srcstring—Image source. Required.
altstring—Alternative text. Required.
material(map: Texture, pointer: Pointer) => ReactNode—Custom R3F material. Receives the loaded texture and a live pointer.
segmentsnumber1Plane geometry subdivisions. Increase for vertex-displacing shaders.
webglEnabledbooleantrueToggle the WebGL plane. When false, only the DOM image is rendered.

Standard <img> attributes (className, style, width, height, ...) are accepted and forwarded to the DOM element.

Pointer

NameTypeDescription
uvVector2Normalized pointer position inside the element. (0, 0) is bottom-left, (1, 1) is top-right.
hovernumber1 while the pointer is over the element, 0 otherwise.
  • Install
  • Basic usage
  • Object-fit
  • With material
  • Toggling WebGL off
  • API
  • Pointer
Star on githubBuy me a coffeellms.txt