Layr Components

Components / Images

Focus Image

A photo that drifts a little inside its frame as the page scrolls and comes from soft to sharp as it arrives in the window.

Scroll insideFull screen
Props
8%
2 px

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

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Focus Image shows up in the Insert menu, and its controls appear in the right panel.
// Focus Image · Layr Components
//
// A photo that drifts a little inside its frame as the page scrolls, and comes
// from soft to sharp as it arrives in the window.
// Paste into a new code file in Framer. Give it a fixed size, like any image.
// Without a photo it shows a plain tone.

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

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

interface FocusImageProps {
    image?: Img
    altText: string
    drift: number
    imageWidth: string
    cornerRadius: number
    style?: CSSProperties
}

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))

const SHEET = `
.lyr-fi{position:relative;width:100%;height:100%;overflow:hidden;container-type:inline-size;isolation:isolate}
.lyr-fi-in{position:absolute;left:0;right:0;top:calc(var(--drift) * -1%);bottom:calc(var(--drift) * -1%);transform:translate3d(0,calc(var(--y) * 1px),0) scale(var(--s));will-change:transform}
.lyr-fi-in img,.lyr-fi-e{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;user-select:none;-webkit-user-drag:none}
.lyr-fi-b{filter:blur(max(12px,3cqw));transform:scale(1.1);opacity:var(--bo)}
.lyr-fi-s{opacity:var(--k)}
`

// Shown when a photo is not set yet.
const EMPTY = "linear-gradient(165deg, #6f7a7d, #2f3537)"

/**
 * @framerIntrinsicWidth 480
 * @framerIntrinsicHeight 600
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function FocusImage(props: FocusImageProps) {
    const { image, altText = "", drift = 8, imageWidth = "40vw", cornerRadius = 2 } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)

    useEffect(() => {
        const host = root.current
        if (!host || isStatic) return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        let raf = 0
        let last = 0
        let goalK = 1
        let goalY = 0
        let k = -1
        let y = 0

        const read = () => {
            const r = host.getBoundingClientRect()
            const vh = window.innerHeight
            // 0 when the top edge reaches the bottom of the window, 1 when
            // the bottom edge leaves through the top.
            const t = clamp((vh - r.top) / (vh + r.height), 0, 1)
            const f = clamp((t - 0.1) / 0.32, 0, 1)
            goalK = f * f * (3 - 2 * f)
            goalY = (0.5 - t) * 2 * ((r.height * drift) / 100)
        }

        const paint = () => {
            host.style.setProperty("--k", k.toFixed(3))
            host.style.setProperty("--bo", (1 - Math.pow(k, 4)).toFixed(3))
            host.style.setProperty("--s", (1.08 - 0.08 * k).toFixed(4))
            host.style.setProperty("--y", y.toFixed(2))
        }

        const tick = (time: number) => {
            raf = 0
            const dt = Math.min(64, Math.max(1, time - last))
            last = time
            const a = 1 - Math.exp(-dt / 130)
            if (k < 0) {
                k = goalK
                y = goalY
            } else {
                k += (goalK - k) * a
                y += (goalY - y) * a
            }
            if (Math.abs(goalK - k) < 0.002) k = goalK
            if (Math.abs(goalY - y) < 0.2) y = goalY
            paint()
            if (k !== goalK || y !== goalY) raf = requestAnimationFrame(tick)
        }

        const wake = () => {
            read()
            if (!raf) {
                last = performance.now()
                raf = requestAnimationFrame(tick)
            }
        }

        wake()
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [isStatic, drift])

    const vars = { "--drift": String(drift), "--k": "1", "--bo": "0", "--s": "1", "--y": "0" } as CSSProperties
    const alt = altText || image?.alt || ""

    return (
        <div ref={root} className="lyr-fi" style={{ ...props.style, ...vars, borderRadius: cornerRadius }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-fi-in">
                {image?.src ? (
                    <>
                        <img className="lyr-fi-b" srcSet={image.srcSet} sizes={imageWidth} src={image.src} alt="" loading="lazy" decoding="async" draggable={false} />
                        <img className="lyr-fi-s" srcSet={image.srcSet} sizes={imageWidth} src={image.src} alt={alt} loading="lazy" decoding="async" draggable={false} />
                    </>
                ) : (
                    <div className="lyr-fi-e" role="img" aria-label={alt || "Photo"} style={{ background: EMPTY }} />
                )}
            </div>
        </div>
    )
}

addPropertyControls(FocusImage, {
    image: { type: ControlType.ResponsiveImage, title: "Photo" },
    altText: {
        type: ControlType.String,
        title: "Alt text",
        defaultValue: "",
        description: "Describes the photo for screen readers. Leave empty to use the photo's own description.",
    },
    drift: {
        type: ControlType.Number,
        title: "Drift",
        defaultValue: 8,
        min: 0,
        max: 20,
        step: 1,
        unit: "%",
        description: "How far the photo moves inside its frame while the page scrolls. 0 keeps it still.",
    },
    imageWidth: {
        type: ControlType.String,
        title: "Image width",
        defaultValue: "40vw",
        description: "How wide the photo is on screen, for example 40vw. Used to load the right image size.",
    },
    cornerRadius: { type: ControlType.Number, title: "Radius", defaultValue: 2, min: 0, max: 40, step: 1, unit: "px" },
})

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.