Layr Components

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
12 px
#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.