Components / Images
Placements
One photo cropped into six labelled ad formats; as you scroll the crops slide and grow until they join into the single picture.
Scroll insideFull screen
Props
#FFFFFF
#F5F4F1
#0E0E0D
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. Placements shows up in the Insert menu, and its controls appear in the right panel.
// Placements · Layr Components
//
// One photo set to six ad sizes: leaderboard, square, story, skyscraper, billboard
// and rectangle. As it scrolls into view the six crops slide and grow until they
// are six windows onto one picture, and the gaps between them close.
// Paste into a new code file in Framer. Give it a width; its height follows (790 by
// 748). Without a photo the six windows show one soft tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }
interface PlacementsProps {
photo?: Img
names: boolean
gap: number
motion: "Follows scroll" | "Apart" | "Joined"
fontFamily: string
imageWidth: string
pageColour: string
tintColour: string
onPhotoColour: string
style?: CSSProperties
}
// left, top, width, height as parts of a 790 x 748 board, and the name of the format
const WINDOWS: [number, number, number, number, string][] = [
[0, 0, 790, 104, "Leaderboard"],
[0, 104, 416, 422, "Square"],
[416, 104, 243, 422, "Story"],
[659, 104, 131, 422, "Skyscraper"],
[0, 526, 524, 222, "Billboard"],
[524, 526, 266, 222, "Rectangle"],
]
const BW = 790,
BH = 748
const SHEET = `
.lyr-plc{position:relative;width:100%;aspect-ratio:790/748;--lyr-plc-g:12px;--lyr-plc-t:1;-webkit-font-smoothing:antialiased;overflow:hidden}
.lyr-plc-w{position:absolute;overflow:hidden;background:var(--lyr-plc-tint)}
.lyr-plc-w img{position:absolute;left:0;top:0;width:100%;height:100%;max-width:none;object-fit:cover;display:block;transform-origin:0 0;will-change:transform}
.lyr-plc-empty{position:absolute;inset:0;background:radial-gradient(70% 60% at 36% 42%,rgba(255,255,255,.16),rgba(255,255,255,0) 70%),linear-gradient(160deg,rgba(255,255,255,.05),rgba(0,0,0,.18))}
.lyr-plc-w:after{content:"";position:absolute;inset:0;box-shadow:inset 0 0 0 calc(var(--lyr-plc-g) * .5) var(--lyr-plc-page);pointer-events:none}
.lyr-plc-n{position:absolute;left:calc(var(--lyr-plc-g) * .5 + 10px);top:calc(var(--lyr-plc-g) * .5 + 8px);z-index:1;font-size:13px;line-height:1.2;font-weight:500;letter-spacing:-.005em;color:var(--lyr-plc-on);opacity:var(--lyr-plc-t);white-space:nowrap;text-shadow:0 0 14px rgba(255,255,255,.6),0 0 3px rgba(255,255,255,.4)}
@supports (color:oklch(from red clamp(0,(.6 - l) * 1000,1) 0 0)){.lyr-plc-n{--lyr-plc-glow:oklch(from var(--lyr-plc-on) clamp(0,(.6 - l) * 1000,1) 0 0);text-shadow:0 0 14px rgb(from var(--lyr-plc-glow) r g b / .55),0 0 3px rgb(from var(--lyr-plc-glow) r g b / .35)}}
.lyr-plc-bare .lyr-plc-n{display:none}
`
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
const ease = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
/**
* @framerIntrinsicWidth 790
* @framerIntrinsicHeight 748
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
*/
export default function Placements(props: PlacementsProps) {
const {
photo,
names = true,
gap = 12,
motion = "Follows scroll",
fontFamily = "Inter",
imageWidth = "60vw",
pageColour = "#0A0A09",
tintColour = "#1B1B19",
onPhotoColour = "#0F0F0E",
style,
} = props
const isStatic = useIsStaticRenderer()
const root = useRef<HTMLDivElement>(null)
const mode = String(motion || "").toLowerCase()
const fixed = mode.includes("joined") ? 1 : isStatic || mode.includes("apart") ? 0 : null
const g = Math.max(0, Number(gap) || 0)
const img = photo && photo.src ? photo : null
useEffect(() => {
const el = root.current
if (!el) return
const imgs = Array.from(el.querySelectorAll<HTMLImageElement>(".lyr-plc-w img"))
let W = 0,
H = 0,
iw = 0,
ih = 0
const measure = () => {
const r = el.getBoundingClientRect()
W = r.width
H = r.height
iw = imgs.length ? imgs[0].naturalWidth : 0
ih = imgs.length ? imgs[0].naturalHeight : 0
}
const paint = (p: number) => {
if (W && iw && ih) {
// At p = 1 every window shows its own part of one picture that covers the whole board.
const S = Math.max(W / iw, H / ih),
bw = iw * S,
bh = ih * S
const bx = (W - bw) / 2,
by = (H - bh) / 2
WINDOWS.forEach(([l, t, w, h], i) => {
const x = (l / BW) * W,
y = (t / BH) * H,
ww = (w / BW) * W,
hh = (h / BH) * H
const s0 = Math.max(ww / iw, hh / ih),
ow = iw * s0,
oh = ih * s0
const k = lerp(ow, bw, p) / ow
const im = imgs[i]
if (!im) return
im.style.width = ow + "px"
im.style.height = oh + "px"
im.style.objectFit = "fill"
im.style.transform = `translate3d(${lerp((ww - ow) / 2, bx - x, p).toFixed(2)}px,${lerp((hh - oh) / 2, by - y, p).toFixed(2)}px,0) scale(${k.toFixed(5)})`
})
}
el.style.setProperty("--lyr-plc-g", (g * (1 - clamp((p - 0.55) / 0.45))).toFixed(2) + "px")
el.style.setProperty("--lyr-plc-t", String(clamp(1 - p * 3)))
}
const still = fixed !== null || (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches)
let goal = fixed === 1 ? 1 : 0,
now = goal,
raf = 0,
last = 0
const read = () => {
if (still) return
const vh = window.innerHeight,
r = el.getBoundingClientRect()
const from = vh * 0.82
let to = vh * 0.5 - r.height * 0.5
const reach = r.top + window.scrollY - (document.documentElement.scrollHeight - vh)
if (reach > to) to = reach + 8
goal = from - to > 24 ? clamp((from - r.top) / (from - to)) : r.top < vh ? 1 : 0
}
const tick = (t: number) => {
const dt = last ? Math.min(64, t - last) : 16
last = t
now += (goal - now) * (1 - Math.exp(-dt / 130))
if (Math.abs(goal - now) < 0.0006) now = goal
paint(ease(now))
raf = now === goal ? 0 : requestAnimationFrame(tick)
if (!raf) last = 0
}
const wake = () => {
measure()
read()
if (!raf) raf = requestAnimationFrame(tick)
}
const loaded = () => {
measure()
paint(ease(now))
wake()
}
imgs.forEach((im) => {
if (!im.complete) im.addEventListener("load", loaded, { once: true })
})
measure()
paint(ease(now))
wake()
window.addEventListener("scroll", wake, { passive: true })
window.addEventListener("resize", wake)
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
measure()
paint(ease(now))
})
: null
if (ro) ro.observe(el)
return () => {
window.removeEventListener("scroll", wake)
window.removeEventListener("resize", wake)
imgs.forEach((im) => im.removeEventListener("load", loaded))
if (ro) ro.disconnect()
if (raf) cancelAnimationFrame(raf)
}
}, [fixed, g, img ? img.src : ""])
const vars = { "--lyr-plc-page": pageColour, "--lyr-plc-tint": tintColour, "--lyr-plc-on": onPhotoColour, "--lyr-plc-g": g + "px" } as CSSProperties
return (
<div ref={root} className={"lyr-plc" + (names ? "" : " lyr-plc-bare")} style={{ ...style, ...vars, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
{WINDOWS.map(([l, t, w, h, name], i) => (
<div className="lyr-plc-w" key={i} style={{ left: (l / BW) * 100 + "%", top: (t / BH) * 100 + "%", width: (w / BW) * 100 + "%", height: (h / BH) * 100 + "%" }}>
{img ? (
<img loading="lazy" decoding="async" sizes={imageWidth || "60vw"} srcSet={img.srcSet} src={img.src} alt={i === 1 ? img.alt || "" : ""} style={{ objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
) : (
// Without a photo: one soft tone across the board, cut into the six windows.
<i className="lyr-plc-empty" style={{ backgroundSize: `${(BW / w) * 100}% ${(BH / h) * 100}%`, backgroundPosition: `${w < BW ? (l / (BW - w)) * 100 : 0}% ${h < BH ? (t / (BH - h)) * 100 : 0}%` }} />
)}
<span className="lyr-plc-n">{name}</span>
</div>
))}
</div>
)
}
addPropertyControls(Placements, {
photo: { type: ControlType.ResponsiveImage, title: "Photo", description: "One photo. The six windows crop it by themselves; a subject near the middle works best." },
names: { type: ControlType.Boolean, title: "Format names", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
gap: { type: ControlType.Number, title: "Gap", min: 0, max: 40, step: 1, unit: "px", defaultValue: 12, description: "Space between the six windows before they join." },
motion: { type: ControlType.Enum, title: "Motion", options: ["Follows scroll", "Apart", "Joined"], optionTitles: ["Follows scroll", "Apart", "Joined"], defaultValue: "Follows scroll", description: "Apart and Joined hold it still as six crops or as one picture." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "60vw", description: "How wide the whole board is drawn at most, for choosing a file size." },
pageColour: { type: ControlType.Color, title: "Page colour", defaultValue: "#0A0A09", description: "The colour of the gaps. Use the page background." },
tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19" },
onPhotoColour: { type: ControlType.Color, title: "On photo colour", defaultValue: "#0F0F0E", description: "The names on the six windows. Dark suits pale photos, white suits dark ones." },
})
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.