Layr Components

Components / Scroll scene

Focal Scene

A pinned list beside one large photo; scrolling zooms through each photo into the next, like a zoom lens, and opens that row's text.

Scroll insideFull screen
Props
70 vh
0
#0E0E0D
#6E6E6B
#E6E5E1
#F4F2EC

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

Scroll inside the preview, or click a row.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Focal Scene shows up in the Insert menu, and its controls appear in the right panel.
// Focal Scene · Layr Components
//
// A pinned list beside one large photo. Scrolling zooms from one item to the
// next like turning a zoom lens: the photo you leave is zoomed through and
// falls out of focus, the next comes in sharp, and its row opens to show its
// text. A row can be clicked to go straight to it.
// Paste into a new code file in Framer. Place it in the page flow at full
// width; it is taller than the window and pins itself while it plays.
// Without photos it shows plain tones.

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

type Img = { src: string; srcSet?: string; alt?: string }
type Item = { image?: Img; tag?: string; title?: string; detail?: string; note?: string }

interface FocalSceneProps {
    items: Item[]
    travel: number
    imageWidth: string
    preview: number
    fontFamily: string
    ink: string
    muted: string
    line: string
    onPhoto: string
    style?: CSSProperties
}

const ITEMS: Item[] = [
    { tag: "24 mm", title: "Places", detail: "Wide views of land, coast and towns for guides, hotels and travel brands, planned around the weather.", note: "Two to four days on location" },
    { tag: "50 mm", title: "Interiors", detail: "Rooms as they are lived in, in the light that is already there. For architects, makers and magazines.", note: "One or two days" },
    { tag: "85 mm", title: "Portraits", detail: "People at work or at home, unhurried and in one place. For features, books and teams.", note: "Half a day" },
]

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const two = (n: number) => String(n).padStart(2, "0")

// Shown when an item has no photo yet.
const EMPTY = ["linear-gradient(160deg, #6f7a7d, #2f3537)", "linear-gradient(160deg, #8a7a66, #3d342b)", "linear-gradient(160deg, #5f5a66, #28252c)"]

// Hold each item for a while, then zoom to the next one.
function plateau(raw: number): number {
    const i = Math.floor(raw)
    const f = raw - i
    const hold = 0.24
    const u = clamp((f - hold) / (1 - 2 * hold), 0, 1)
    return i + u * u * (3 - 2 * u)
}

// How one photograph looks when the scene is at position p. The one you are
// leaving is zoomed through and loses focus; the next one waits underneath,
// out of focus, and comes in sharp.
function shotVars(i: number, p: number): Record<string, string> {
    const d = i - p
    const k0 = clamp(1 - Math.abs(d) / 0.6, 0, 1)
    const k = k0 * k0 * (3 - 2 * k0)
    const s = d < 0 ? 1 + Math.min(1, -d) * 0.55 : 1 - Math.min(1, d) * 0.07
    const o = d < 0 ? clamp(1 + d * 1.5, 0, 1) : 1
    return {
        "--s": s.toFixed(4),
        "--o": o.toFixed(3),
        "--k": k.toFixed(3),
        "--bo": (1 - Math.pow(k, 4)).toFixed(3),
        "--z": String(20 - i),
    }
}

const SHEET = `
.lyr-fc{position:relative;width:100%}
.lyr-fc-pin{position:sticky;top:0;width:100%;height:100vh;height:100dvh;container-type:size;isolation:isolate;overflow:clip}
.lyr-fc-grid{position:absolute;inset:0;box-sizing:border-box;padding:96px 40px 40px;display:grid;grid-template-columns:minmax(0,42fr) minmax(0,58fr);column-gap:40px}
.lyr-fc[data-static="1"] .lyr-fc-pin{position:relative;height:760px}
.lyr-fc-list{align-self:end;display:flex;flex-direction:column;border-bottom:1px solid var(--line);min-width:0}
.lyr-fc-row{display:block;width:100%;padding:22px 0;margin:0;border:0;border-top:1px solid var(--line);background:none;color:var(--muted);font:inherit;text-align:left;cursor:pointer;transition:color .3s}
.lyr-fc-row[data-on="1"]{color:var(--ink);cursor:default}
.lyr-fc-row:focus-visible{outline:1px solid var(--ink);outline-offset:2px}
.lyr-fc-top{display:flex;align-items:baseline;gap:18px}
.lyr-fc-top i{font-style:normal;font-size:14px;font-weight:500;width:64px;flex:none;font-variant-numeric:tabular-nums;white-space:nowrap}
.lyr-fc-top b{font-weight:400;font-size:clamp(26px,3.3cqw,46px);line-height:1.06;letter-spacing:-.035em;min-width:0}
.lyr-fc-more{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .5s cubic-bezier(.3,.7,.2,1),opacity .35s}
.lyr-fc-row[data-on="1"] .lyr-fc-more{grid-template-rows:1fr;opacity:1;transition-delay:0s,.12s}
.lyr-fc-more>div{overflow:hidden;min-height:0}
.lyr-fc-text{padding:16px 0 2px 82px;max-width:520px;font-size:16px;line-height:1.5;letter-spacing:-.01em;color:var(--muted)}
.lyr-fc-text span{display:block;margin-top:12px;font-size:14px;color:var(--ink)}
.lyr-fc-big{position:absolute;left:40px;top:96px;font-size:clamp(72px,12cqw,190px);line-height:.9;letter-spacing:-.055em;font-weight:400;white-space:nowrap;font-variant-numeric:tabular-nums}
.lyr-fc-big span{display:inline-block;animation:lyr-fc-in .45s cubic-bezier(.3,.7,.2,1) both}
.lyr-fc-stage{position:relative;height:100%;min-height:0;overflow:hidden;border-radius:2px}
.lyr-fc-shot{position:absolute;inset:-6%;opacity:var(--o);z-index:var(--z);transform:scale(var(--s));will-change:transform,opacity}
.lyr-fc-shot img,.lyr-fc-e{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;user-select:none;-webkit-user-drag:none}
.lyr-fc-b{filter:blur(max(12px,1.7cqw));opacity:var(--bo);transform:scale(1.08)}
.lyr-fc-s{opacity:var(--k)}
.lyr-fc-cap{position:absolute;left:18px;right:18px;bottom:16px;z-index:40;display:flex;justify-content:space-between;align-items:baseline;font-size:14px;letter-spacing:-.005em;color:var(--onphoto);font-variant-numeric:tabular-nums;text-shadow:0 1px 14px rgba(0,0,0,.45)}
.lyr-fc-cap b{font-weight:500}
.lyr-fc-cap span{animation:lyr-fc-in .3s ease both}
@keyframes lyr-fc-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@container (max-aspect-ratio:1/1){
.lyr-fc-grid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto;row-gap:20px;padding:76px 16px 20px}
.lyr-fc-stage{order:-1}
.lyr-fc-big{display:none}
.lyr-fc-list{align-self:start}
.lyr-fc-row{padding:16px 0}
.lyr-fc-top i{width:52px}
.lyr-fc-text{padding-left:70px;font-size:15px}
}
@media (prefers-reduced-motion:reduce){.lyr-fc-more{transition:none}.lyr-fc-cap span{animation:none}}
`

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 760
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function FocalScene(props: FocalSceneProps) {
    const {
        items = ITEMS,
        travel = 70,
        imageWidth = "58vw",
        preview = 0,
        fontFamily = "Inter",
        ink = "#ECE9E2",
        muted = "#8E8B84",
        line = "rgba(236,233,226,0.14)",
        onPhoto = "#F4F2EC",
    } = props
    const isStatic = useIsStaticRenderer()
    const list = (items || []).filter((item) => item && (item.title || item.image?.src))
    const [active, setActive] = useState(0)
    const root = useRef<HTMLDivElement>(null)
    const shots = useRef<(HTMLDivElement | null)[]>([])
    const pRef = useRef(0)
    const activeRef = useRef(0)
    const countRef = useRef(0)
    const jump = useRef<(i: number) => void>(() => {})

    const n = list.length
    countRef.current = n

    useEffect(() => {
        const host = root.current
        if (!host || isStatic || n < 1) return
        const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
        let raf = 0
        let t = 0
        let last = 0
        let lastScroll = 0
        let top = 0
        let range = 0

        const read = () => {
            const r = host.getBoundingClientRect()
            range = r.height - window.innerHeight
            top = r.top + window.scrollY
            t = range > 1 ? clamp(-r.top / range, 0, 1) : 0
        }

        const paint = (p: number) => {
            pRef.current = p
            const count = countRef.current
            for (let i = 0; i < count; i++) {
                const el = shots.current[i]
                if (!el) continue
                const vars = shotVars(i, p)
                for (const name in vars) el.style.setProperty(name, vars[name])
            }
            const now = clamp(Math.round(p), 0, Math.max(0, count - 1))
            if (now !== activeRef.current) {
                activeRef.current = now
                startTransition(() => setActive(now))
            }
        }

        const tick = (time: number) => {
            raf = 0
            const count = Math.max(1, countRef.current)
            const idle = time - lastScroll > 170
            let goal = plateau(t * (count - 1))
            if (idle || reduced) goal = Math.round(goal)
            const dt = Math.min(64, Math.max(1, time - last))
            last = time
            let p = reduced ? goal : pRef.current + (goal - pRef.current) * (1 - Math.exp(-dt / 150))
            if (Math.abs(goal - p) < 0.0005) p = goal
            if (p !== pRef.current) paint(p)
            if (p !== goal || !idle) raf = requestAnimationFrame(tick)
        }

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

        const onScroll = () => {
            const before = t
            read()
            if (t !== before) {
                lastScroll = performance.now()
                wake()
            }
        }

        const onResize = () => {
            read()
            wake()
        }

        jump.current = (i: number) => {
            read()
            const count = Math.max(1, countRef.current)
            window.scrollTo({ top: top + (count > 1 ? i / (count - 1) : 0) * Math.max(0, range), behavior: reduced ? "auto" : "smooth" })
        }

        read()
        paint(Math.round(t * Math.max(0, n - 1)))
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onResize)
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onResize)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [n, isStatic])

    const p = isStatic ? clamp(preview, 0, Math.max(0, n - 1)) : pRef.current
    const shown = isStatic ? Math.round(p) : Math.min(active, Math.max(0, n - 1))
    const current = list[shown]
    const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
    const height = isStatic ? undefined : `calc(100vh + ${Math.max(0, n - 1) * travel}vh)`
    const vars = { "--ink": ink, "--muted": muted, "--line": line, "--onphoto": onPhoto } as CSSProperties

    return (
        <div ref={root} className="lyr-fc" data-static={isStatic ? "1" : "0"} style={{ ...props.style, ...vars, height, fontFamily: font, color: ink }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-fc-pin">
                <div className="lyr-fc-grid">
                    {current && current.tag && (
                        <div className="lyr-fc-big" aria-hidden="true">
                            <span key={shown}>{current.tag}</span>
                        </div>
                    )}
                    <div className="lyr-fc-list">
                        {list.map((item, i) => (
                            <button key={i} type="button" className="lyr-fc-row" data-on={i === shown ? "1" : "0"} aria-expanded={i === shown} onClick={() => jump.current(i)}>
                                <div className="lyr-fc-top">
                                    <i>{item.tag || two(i + 1)}</i>
                                    <b>{item.title}</b>
                                </div>
                                <div className="lyr-fc-more">
                                    <div>
                                        <div className="lyr-fc-text">
                                            {item.detail}
                                            {item.note && <span>{item.note}</span>}
                                        </div>
                                    </div>
                                </div>
                            </button>
                        ))}
                    </div>
                    <div className="lyr-fc-stage">
                        {list.map((item, i) => {
                            const img = item.image
                            return (
                                <div
                                    key={i}
                                    ref={(el) => {
                                        shots.current[i] = el
                                    }}
                                    className="lyr-fc-shot"
                                    style={shotVars(i, p) as CSSProperties}
                                >
                                    {img?.src ? (
                                        <>
                                            <img className="lyr-fc-b" srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt="" loading="lazy" decoding="async" draggable={false} />
                                            <img className="lyr-fc-s" srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt={img.alt || item.title || ""} loading="lazy" decoding="async" draggable={false} />
                                        </>
                                    ) : (
                                        <div className="lyr-fc-e" style={{ background: EMPTY[i % EMPTY.length] }} />
                                    )}
                                </div>
                            )
                        })}
                        {current && (
                            <div className="lyr-fc-cap">
                                <span key={shown}>
                                    <b>{current.title}</b>
                                </span>
                                <span key={shown + "n"}>
                                    {two(shown + 1)} of {two(n)}
                                </span>
                            </div>
                        )}
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(FocalScene, {
    items: {
        type: ControlType.Array,
        title: "Items",
        maxCount: 12,
        control: {
            type: ControlType.Object,
            controls: {
                image: { type: ControlType.ResponsiveImage, title: "Photo" },
                tag: { type: ControlType.String, title: "Tag", defaultValue: "", description: "A short label shown large beside the photo, such as a focal length or a number." },
                title: { type: ControlType.String, title: "Title", defaultValue: "" },
                detail: { type: ControlType.String, title: "Text", defaultValue: "", displayTextArea: true },
                note: { type: ControlType.String, title: "Note", defaultValue: "" },
            },
        },
        defaultValue: ITEMS,
        description: "One row and one photo per item, in order.",
    },
    travel: {
        type: ControlType.Number,
        title: "Scroll per item",
        defaultValue: 70,
        min: 30,
        max: 160,
        step: 5,
        unit: "vh",
        description: "How much scrolling it takes to move from one item to the next.",
    },
    imageWidth: {
        type: ControlType.String,
        title: "Image width",
        defaultValue: "58vw",
        description: "How wide the photograph is on screen, for example 58vw. Used to load the right image size.",
    },
    preview: {
        type: ControlType.Number,
        title: "Canvas preview",
        defaultValue: 0,
        min: 0,
        max: 12,
        step: 1,
        displayStepper: true,
        description: "Which item the canvas shows. The live site follows the scroll.",
    },
    fontFamily: {
        type: ControlType.String,
        title: "Font family",
        defaultValue: "Inter",
        description: "Use the family name of your text styles.",
    },
    ink: { type: ControlType.Color, title: "Text colour", defaultValue: "#ECE9E2" },
    muted: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8E8B84" },
    line: { type: ControlType.Color, title: "Line colour", defaultValue: "rgba(236,233,226,0.14)" },
    onPhoto: { type: ControlType.Color, title: "On photo", defaultValue: "#F4F2EC" },
})

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.