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