Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Components (19)

  • 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
  • Simple Scramble
    new
  • Text Bounce
    new
  • Text Fluid
    pro
  • Text Roll
    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. Curve Image

Curve Image

An SEO-friendly WebGL image with scroll-velocity driven bend and chromatic aberration.

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

Settings

amplitude
0.03
aberration
0.003
smoothing
6.0
segments
32
webgl-enabled
See the documentation below for more options.

CLI Install

npx atelier-ui add curve-image

Manual Install

npm install three @react-three/fiber
curve-image.tsx
import { shaderMaterial } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import { useEffect, useRef } from "react"
import { MathUtils, Texture } from "three"
import { WebglImage } from "../webgl-image/webgl-image"

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

const vertexShader = /* glsl */ `
    precision highp float;
    varying vec2 vUv;
    uniform float uVelocity;
    uniform float uAmplitude;

    const float PI = 3.14159265;

    void main() {
        vUv = uv;
        vec3 pos = position;

        float bend = sin(uv.x * PI);

        pos.y += bend * uVelocity * uAmplitude;

        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
`

const fragmentShader = /* glsl */ `
    precision highp float;
    varying vec2 vUv;
    uniform sampler2D uMap;
    uniform float uVelocity;
    uniform float uAberration;

    void main() {
        float shift = uVelocity * uAberration;
        vec2 offset = vec2(0.0, shift);

        float r = texture2D(uMap, vUv + offset).r;
        float g = texture2D(uMap, vUv).g;
        float b = texture2D(uMap, vUv - offset).b;

        gl_FragColor = vec4(r, g, b, 1.0);
    }
`

const CurveImageMat = shaderMaterial(
    {
        uMap: new Texture(),
        uVelocity: 0,
        uAmplitude: 0,
        uAberration: 0,
    },
    vertexShader,
    fragmentShader,
)

extend({ CurveImageMat })

type CurveImageMaterialProps = {
    map: Texture
    amplitude: number
    aberration: number
    smoothing: number
}

export type CurveImageProps = {
    src: string
    alt: string
    amplitude?: number
    aberration?: number
    smoothing?: number
    segments?: number
    webglEnabled?: boolean
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">

function CurveImageMaterial({ map, amplitude, aberration, smoothing }: CurveImageMaterialProps) {
    const ref = useRef<InstanceType<typeof CurveImageMat>>(null)
    const lastScrollY = useRef(0)
    const velocity = useRef(0)

    useEffect(() => {
        lastScrollY.current = window.scrollY
    }, [])

    useFrame((_, delta) => {
        const mat = ref.current
        if (!mat) return
        const current = window.scrollY
        const instantDelta = current - lastScrollY.current
        lastScrollY.current = current

        if (delta === 0) return
        const target = instantDelta / delta / window.innerHeight
        velocity.current = MathUtils.damp(velocity.current, target, smoothing, delta)
        mat.uVelocity = velocity.current
    })

    return (
        <curveImageMat
            ref={ref}
            key={CurveImageMat.key}
            uMap={map}
            uAmplitude={amplitude}
            uAberration={aberration}
            transparent
        />
    )
}

export function CurveImage({
    src,
    alt,
    amplitude = 0.03,
    aberration = 0.003,
    smoothing = 6,
    segments = 32,
    webglEnabled = true,
    ...rest
}: CurveImageProps) {
    return (
        <WebglImage
            src={src}
            alt={alt}
            segments={segments}
            webglEnabled={webglEnabled}
            material={(map) => (
                <CurveImageMaterial
                    map={map}
                    amplitude={amplitude}
                    aberration={aberration}
                    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
    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>
            )}
        </>
    )
}

API

NameTypeDefaultDescription
srcstring—Image URL bound to the GPU plane.
altstring—Alt text for the underlying <img>.
amplitudenumber0.03Maximum bend intensity, applied to the smoothed scroll velocity.
aberrationnumber0.003RGB channel split along the scroll axis, scaled by velocity.
smoothingnumber6Exponential smoothing for the velocity (higher = snappier, lower = floatier).
segmentsnumber32Plane subdivision count. Higher = smoother curve, more vertices.
webglEnabledbooleantrueWhen false, the WebGL plane is skipped and the plain <img> is rendered as-is.
...propsImgHTMLAttributes—Other <img> attributes forwarded to the underlying element.

Credits

React Three Fiber
React renderer for Three.js used for the WebGL plane.

Lenis
Smooth scroll library used in the demo.

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