Layr Components

Components / Images

Lit Image

A photo lit by WebGL: one light rakes across it as you scroll or follows the pointer, so the ridges of paint or texture catch it.

Scroll insideFull screen
Props
40
35

Set in code: Image. The preview fills these in with demo content.

Scroll, or move the pointer over the picture.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Lit Image shows up in the Insert menu, and its controls appear in the right panel.
// Lit Image · Layr Components
//
// A photograph of a textured surface that takes the light. As the visitor
// scrolls, or moves the pointer over it, one light rakes across the picture and
// its ridges catch it while the hollows fall away. Drawn with WebGL; where WebGL
// is not available the plain photograph shows instead.
// Paste into a new code file in Framer. Give it a fixed size; the photo fills it
// (or fits inside it). Without a photo it shows a plain tone.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

type Picture = { src: string; srcSet?: string; alt?: string }

interface LitImageProps {
    image?: Picture
    altText: string
    relief: number
    shine: number
    follow: string
    fit: string
    imageSizes: string
    style?: CSSProperties
}

const VERT = "attribute vec2 p;varying vec2 v;void main(){v=p*0.5+0.5;v.y=1.0-v.y;gl_Position=vec4(p,0.0,1.0);}"
// Brightness stands in for the height of the surface. One light, moved by the
// scroll and the pointer, rakes across it: ridges catch it, hollows fall away.
const FRAG = `precision mediump float;
uniform sampler2D t;uniform vec2 px;uniform vec2 sc;uniform vec2 of;uniform vec3 l;uniform float relief;uniform float shine;varying vec2 v;
float h(vec2 uv){vec3 c=texture2D(t,uv).rgb;return dot(c,vec3(0.299,0.587,0.114));}
void main(){
vec2 uv=v*sc+of;
vec3 c=texture2D(t,uv).rgb;
float dx=(h(uv-vec2(px.x,0.0))-h(uv+vec2(px.x,0.0)))+0.5*(h(uv-vec2(px.x*2.5,0.0))-h(uv+vec2(px.x*2.5,0.0)));
float dy=(h(uv-vec2(0.0,px.y))-h(uv+vec2(0.0,px.y)))+0.5*(h(uv-vec2(0.0,px.y*2.5))-h(uv+vec2(0.0,px.y*2.5)));
vec3 n=normalize(vec3(dx*relief,dy*relief,1.0));
vec3 ld=normalize(l);
float d=clamp(dot(n,ld)/ld.z,0.5,1.7);
vec3 hv=normalize(ld+vec3(0.0,0.0,1.0));
float s=pow(max(dot(n,hv),0.0),48.0)-pow(max(hv.z,0.0),48.0);
gl_FragColor=vec4(c*d+vec3(1.0,0.97,0.9)*s*shine,1.0);
}`

/**
 * @framerIntrinsicWidth 900
 * @framerIntrinsicHeight 600
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function LitImage(props: LitImageProps) {
    const { image, altText = "", relief = 40, shine = 35, follow = "Scroll and pointer", fit = "Fill", imageSizes = "100vw", style } = props
    const isStatic = useIsStaticRenderer()
    const rootRef = useRef<HTMLDivElement>(null)
    const imgRef = useRef<HTMLImageElement>(null)
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const mode = String(follow).toLowerCase()
    const cover = !String(fit).toLowerCase().includes("fit")

    useEffect(() => {
        if (isStatic || mode.includes("off")) return
        const root = rootRef.current
        const img = imgRef.current
        const canvas = canvasRef.current
        if (!root || !img || !canvas) return
        const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
        let gl: WebGLRenderingContext | null = null
        let program: WebGLProgram | null = null
        let ready = false
        let visible = false
        let raf = 0
        let pointer: { x: number; y: number } | null = null
        let natural = { w: 1, h: 1 }
        const uni: Record<string, WebGLUniformLocation | null> = {}

        const draw = () => {
            raf = 0
            if (!gl || !ready) return
            const rect = root.getBoundingClientRect()
            const dpr = Math.min(window.devicePixelRatio || 1, 2)
            const w = Math.max(1, Math.round(Math.min(rect.width * dpr, 2048)))
            const hgt = Math.max(1, Math.round(w * (rect.height / Math.max(1, rect.width))))
            if (canvas.width !== w || canvas.height !== hgt) {
                canvas.width = w
                canvas.height = hgt
                gl.viewport(0, 0, w, hgt)
            }
            // Same crop as object-fit: cover (or contain).
            const boxR = rect.width / Math.max(1, rect.height)
            const imgR = natural.w / natural.h
            let sx = 1
            let sy = 1
            if (cover ? imgR > boxR : imgR < boxR) sx = boxR / imgR
            else sy = imgR / boxR
            gl.uniform2f(uni.sc, sx, sy)
            gl.uniform2f(uni.of, (1 - sx) / 2, (1 - sy) / 2)
            gl.uniform2f(uni.px, (sx / w) * 1.6, (sy / hgt) * 1.6)
            // Light: across the picture with the scroll, toward the pointer when it is over it.
            const vh = window.innerHeight
            const through = Math.min(1, Math.max(0, (vh - rect.top) / (vh + rect.height)))
            let lx = Math.cos(Math.PI * (1.05 - through * 1.1))
            let ly = 0.35
            if (pointer && !mode.startsWith("scroll")) {
                lx = (pointer.x - (rect.left + rect.width / 2)) / (rect.width / 2)
                ly = -(pointer.y - (rect.top + rect.height / 2)) / (rect.height / 2)
            } else if (mode.startsWith("pointer")) {
                lx = -0.7
                ly = 0.5
            }
            if (reduce) {
                lx = -0.7
                ly = 0.5
            }
            gl.uniform3f(uni.l, lx, ly, 0.55)
            gl.uniform1f(uni.relief, (relief / 100) * 7)
            gl.uniform1f(uni.shine, (shine / 100) * 0.9)
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
            canvas.style.opacity = "1"
        }
        const ask = () => {
            if (!raf && visible) raf = requestAnimationFrame(draw)
        }

        const start = () => {
            const source = img.currentSrc || img.src
            if (!source) return
            try {
                gl = (canvas.getContext("webgl", { premultipliedAlpha: false, antialias: false }) || canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null
            } catch (e) {
                gl = null
            }
            if (!gl) return
            const g = gl
            const compile = (type: number, text: string) => {
                const sh = g.createShader(type)
                if (!sh) return null
                g.shaderSource(sh, text)
                g.compileShader(sh)
                return sh
            }
            const vs = compile(g.VERTEX_SHADER, VERT)
            const fs = compile(g.FRAGMENT_SHADER, FRAG)
            program = g.createProgram()
            if (!program || !vs || !fs) {
                gl = null
                return
            }
            g.attachShader(program, vs)
            g.attachShader(program, fs)
            g.linkProgram(program)
            if (!g.getProgramParameter(program, g.LINK_STATUS)) {
                gl = null
                return
            }
            g.useProgram(program)
            const buffer = g.createBuffer()
            g.bindBuffer(g.ARRAY_BUFFER, buffer)
            g.bufferData(g.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), g.STATIC_DRAW)
            const loc = g.getAttribLocation(program, "p")
            g.enableVertexAttribArray(loc)
            g.vertexAttribPointer(loc, 2, g.FLOAT, false, 0, 0)
            for (const name of ["t", "px", "sc", "of", "l", "relief", "shine"]) uni[name] = g.getUniformLocation(program, name)
            const tex = new Image()
            tex.crossOrigin = "anonymous"
            tex.onload = () => {
                if (!gl) return
                natural = { w: tex.naturalWidth, h: tex.naturalHeight }
                const texture = gl.createTexture()
                gl.bindTexture(gl.TEXTURE_2D, texture)
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
                try {
                    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, tex)
                } catch (e) {
                    // The host sent no CORS header: keep the plain photograph.
                    gl = null
                    return
                }
                gl.uniform1i(uni.t, 0)
                ready = true
                ask()
            }
            tex.src = source
        }

        // Start only when the picture is near the window, and stop drawing off screen.
        const io = new IntersectionObserver(
            (entries) => {
                visible = entries.some((e) => e.isIntersecting)
                if (visible && !gl && !program) {
                    if (img.complete && img.naturalWidth) start()
                    else img.addEventListener("load", start, { once: true })
                }
                ask()
            },
            { rootMargin: "200px 0px" }
        )
        io.observe(root)
        const onMove = (e: PointerEvent) => {
            if (e.pointerType === "touch") return
            pointer = { x: e.clientX, y: e.clientY }
            ask()
        }
        const onLeave = () => {
            pointer = null
            ask()
        }
        window.addEventListener("scroll", ask, { passive: true })
        window.addEventListener("resize", ask)
        if (!mode.startsWith("scroll")) {
            root.addEventListener("pointermove", onMove)
            root.addEventListener("pointerleave", onLeave)
        }
        return () => {
            io.disconnect()
            if (raf) cancelAnimationFrame(raf)
            window.removeEventListener("scroll", ask)
            window.removeEventListener("resize", ask)
            root.removeEventListener("pointermove", onMove)
            root.removeEventListener("pointerleave", onLeave)
            img.removeEventListener("load", start)
            const lose = gl && gl.getExtension("WEBGL_lose_context")
            if (lose) lose.loseContext()
            gl = null
        }
    }, [isStatic, mode, cover, relief, shine, image?.src])

    const layer: CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%", display: "block", objectFit: cover ? "cover" : "contain" }
    return (
        <div ref={rootRef} style={{ ...style, position: "relative", width: "100%", height: "100%", overflow: "hidden" }}>
            {image?.src ? (
                <img ref={imgRef} srcSet={image.srcSet} sizes={imageSizes} src={image.src} alt={altText || image.alt || ""} loading="lazy" decoding="async" crossOrigin="anonymous" style={layer} />
            ) : (
                <div role="img" aria-label={altText || undefined} style={{ ...layer, background: "linear-gradient(160deg, #d6d0c4, #9c9488)" }} />
            )}
            <canvas ref={canvasRef} aria-hidden="true" style={{ ...layer, objectFit: "fill", opacity: 0, transition: "opacity .6s", pointerEvents: "none" }} />
        </div>
    )
}

addPropertyControls(LitImage, {
    image: { type: ControlType.ResponsiveImage, title: "Image" },
    altText: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "Describes the picture for people who cannot see it." },
    relief: { type: ControlType.Number, title: "Relief", defaultValue: 40, min: 0, max: 100, step: 1, description: "How deep the paint looks. Lower it for smooth work such as watercolour." },
    shine: { type: ControlType.Number, title: "Shine", defaultValue: 35, min: 0, max: 100, step: 1, description: "How strongly ridges catch the light." },
    follow: {
        type: ControlType.Enum,
        title: "Light follows",
        options: ["Scroll and pointer", "Scroll", "Pointer", "Off"],
        optionTitles: ["Scroll and pointer", "Scroll", "Pointer", "Off"],
        defaultValue: "Scroll and pointer",
    },
    fit: { type: ControlType.Enum, title: "Picture", options: ["Fill", "Fit"], optionTitles: ["Fill", "Fit"], defaultValue: "Fill" },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "100vw", description: "One length, for example 60vw." },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.