Layr Components

Components / Scroll scene

Long Gallery

A pinned 3D room with photos hung in facing pairs on two walls; scrolling walks you down the room to the end wall, with captions.

Scroll insideFull screen
Props
80 vh
0
#0E0E0D
#6E6E6B

Set in code: Items, Link. 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. Long Gallery shows up in the Insert menu, and its controls appear in the right panel.
// Long Gallery · Layr Components
//
// A pinned room with photos hung in facing pairs on its two walls. Scrolling
// walks you down the room, past each pair, to the end wall, with captions for
// the pair you are passing. An odd last photo hangs alone on the end wall.
// 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 you walk. No
// ancestor may clip overflow. 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; title?: string; detail?: string; note?: string; link?: string }

interface LongGalleryProps {
    items: Item[]
    travel: number
    imageWidth: string
    link?: string
    linkLabel: string
    preview: number
    fontFamily: string
    ink: string
    muted: string
    style?: CSSProperties
}

const ITEMS: Item[] = [
    { title: "Calm water", detail: "50 × 70 cm", note: "Edition of 20" },
    { title: "Low cloud", detail: "40 × 60 cm", note: "Edition of 25" },
    { title: "Dry ridge", detail: "50 × 70 cm", note: "Edition of 20" },
    { title: "Evening", detail: "60 × 90 cm", note: "Edition of 15" },
    { title: "Morning room", detail: "40 × 60 cm", note: "Edition of 25" },
    { title: "Night room", detail: "50 × 70 cm", note: "Edition of 20" },
    { title: "Last light", detail: "70 × 100 cm", note: "Edition of 10" },
]

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

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

// The prints hang in facing pairs, one on each wall, so the room is the
// same on both sides. With an odd number the last print hangs alone on the
// end wall; with an even number the last two hang there side by side.
function hang(n: number): { rows: number; ends: number } {
    if (n <= 0) return { rows: 0, ends: 0 }
    const ends = n % 2 === 1 ? 1 : Math.min(2, n)
    return { rows: (n - ends) / 2, ends }
}

// The room is drawn with CSS 3D. Every length is a share of the pinned
// stage (cqw), so it is right in the server HTML and at any window size.
// --p is how far you have walked, counted in pairs; one number moves the
// whole room.
const SHEET = `
.lyr-lg{position:relative;width:100%}
.lyr-lg-pin{position:sticky;top:0;width:100%;height:100vh;height:100dvh;overflow:clip;container-type:size;isolation:isolate}
.lyr-lg[data-static="1"] .lyr-lg-pin{position:relative;height:720px}
.lyr-lg-scene{position:absolute;inset:0;perspective:70cqw;perspective-origin:50% 46%;--gap:52cqw;--pw:44cqw;--side:46cqw;--ew:62cqw;--first:-21cqw;--last:-8cqw;--turn:90deg}
.lyr-lg-world{position:absolute;left:50%;top:46%;transform-style:preserve-3d;transform:translateZ(calc(var(--p) * var(--gap)));will-change:transform}
.lyr-lg-print{--d:calc(var(--r) - var(--p));position:absolute;left:0;top:0;width:var(--pw);aspect-ratio:3/2;margin-left:calc(var(--pw) / -2);margin-top:calc(var(--pw) / -3);overflow:hidden;border-radius:1px;display:block;opacity:clamp(0,min(1.08 - var(--d) * .19,(var(--d) + 1.1) * 5),1)}
.lyr-lg-print[data-side="l"]{transform:translate3d(calc(var(--side) * -1),0,calc(var(--r) * var(--gap) * -1 + var(--first))) rotateY(var(--turn))}
.lyr-lg-print[data-side="r"]{transform:translate3d(var(--side),0,calc(var(--r) * var(--gap) * -1 + var(--first))) rotateY(calc(var(--turn) * -1))}
.lyr-lg-print[data-side="e"],.lyr-lg-print[data-side="el"],.lyr-lg-print[data-side="er"]{opacity:clamp(.5,1.3 - var(--d) * .16,1)}
.lyr-lg-print[data-side="e"]{width:var(--ew);margin-left:calc(var(--ew) / -2);margin-top:calc(var(--ew) / -3);transform:translate3d(0,0,calc(var(--r) * var(--gap) * -1 + var(--last)))}
.lyr-lg-print[data-side="el"],.lyr-lg-print[data-side="er"]{--hw:calc(var(--ew) * .6);width:var(--hw);margin-left:calc(var(--hw) / -2);margin-top:calc(var(--hw) / -3)}
.lyr-lg-print[data-side="el"]{transform:translate3d(calc(var(--hw) * -.52),0,calc(var(--r) * var(--gap) * -1 + var(--last)))}
.lyr-lg-print[data-side="er"]{transform:translate3d(calc(var(--hw) * .52),0,calc(var(--r) * var(--gap) * -1 + var(--last)))}
.lyr-lg-print img,.lyr-lg-e{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;user-select:none;-webkit-user-drag:none}
.lyr-lg-cap{position:absolute;left:40px;right:40px;bottom:30px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:end;column-gap:24px;font-size:14px;line-height:1.45;letter-spacing:-.005em;font-variant-numeric:tabular-nums;z-index:5}
.lyr-lg-cap b{font-weight:500;display:block}
.lyr-lg-meta span{white-space:nowrap}
.lyr-lg-meta i{font-style:normal}
.lyr-lg-a,.lyr-lg-b,.lyr-lg-c{animation:lyr-lg-in .3s ease both;min-width:0}
.lyr-lg-b{text-align:center}
.lyr-lg-c{text-align:right}
.lyr-lg-more{position:absolute;left:0;right:0;top:calc(46% + 23cqw);text-align:center;font-size:14px;letter-spacing:-.005em;z-index:5;animation:lyr-lg-in .3s ease both}
.lyr-lg-cap a,.lyr-lg-more a{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:opacity .2s}
.lyr-lg-cap a:hover,.lyr-lg-more a:hover{opacity:.68}
@keyframes lyr-lg-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@container (max-aspect-ratio:1/1){
.lyr-lg-scene{perspective:120cqw;--gap:92cqw;--pw:70cqw;--side:44cqw;--ew:90cqw;--first:-30cqw;--last:-14cqw;--turn:90deg}
.lyr-lg-cap{left:16px;right:16px;bottom:18px;column-gap:12px;font-size:13px}
.lyr-lg-meta span{display:block}
.lyr-lg-meta i{display:none}
.lyr-lg-more{top:calc(46% + 34cqw)}
}
@media (prefers-reduced-motion:reduce){.lyr-lg-a,.lyr-lg-b,.lyr-lg-c,.lyr-lg-more{animation:none}}
`

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 720
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function LongGallery(props: LongGalleryProps) {
    const {
        items = ITEMS,
        travel = 80,
        imageWidth = "40vw",
        link,
        linkLabel = "View all",
        preview = 0,
        fontFamily = "Inter",
        ink = "#ECE9E2",
        muted = "#8E8B84",
    } = props
    const isStatic = useIsStaticRenderer()
    const list = (items || []).filter((item) => item && (item.title || item.image?.src))
    const [near, setNear] = useState(0)
    const root = useRef<HTMLDivElement>(null)
    const world = useRef<HTMLDivElement>(null)
    const pRef = useRef(0)
    const nearRef = useRef(0)
    const rowsRef = useRef(0)

    const n = list.length
    const { rows, ends } = hang(n)
    rowsRef.current = rows

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

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

        const paint = (p: number) => {
            pRef.current = p
            if (world.current) world.current.style.setProperty("--p", p.toFixed(4))
            const now = clamp(Math.round(p + 0.3), 0, rowsRef.current)
            if (now !== nearRef.current) {
                nearRef.current = now
                startTransition(() => setNear(now))
            }
        }

        const tick = (t: number) => {
            raf = 0
            const dt = Math.min(64, Math.max(1, t - last))
            last = t
            let p = reduced ? goal : pRef.current + (goal - pRef.current) * (1 - Math.exp(-dt / 150))
            if (Math.abs(goal - p) < 0.0008) p = goal
            if (p !== pRef.current) paint(p)
            if (p !== goal) raf = requestAnimationFrame(tick)
        }

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

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

    const p = isStatic ? clamp(preview, 0, rows) : pRef.current
    const shown = isStatic ? clamp(Math.round(p + 0.3), 0, rows) : clamp(near, 0, rows)
    const atEnd = shown >= rows
    const left = atEnd ? (ends === 2 ? list[n - 2] : undefined) : list[shown * 2]
    const right = atEnd ? (ends === 2 ? list[n - 1] : undefined) : list[shown * 2 + 1]
    const middle = atEnd && ends === 1 ? list[n - 1] : undefined
    const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
    const height = isStatic ? undefined : `calc(100vh + ${rows * travel}vh)`

    const label = (item: Item | undefined, cls: string) =>
        item ? (
            <div className={cls} key={shown + cls}>
                <b>{item.title}</b>
                <span className="lyr-lg-meta" style={{ color: muted }}>
                    {item.detail ? <span>{item.detail}</span> : null}
                    {item.detail && item.note ? <i aria-hidden="true"> · </i> : null}
                    {item.note ? <span>{item.note}</span> : null}
                </span>
            </div>
        ) : (
            <div className={cls} />
        )

    return (
        <div ref={root} className="lyr-lg" data-static={isStatic ? "1" : "0"} style={{ ...props.style, height, fontFamily: font, color: ink }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-lg-pin">
                <div className="lyr-lg-scene">
                    <div ref={world} className="lyr-lg-world" style={{ "--p": p.toFixed(4) } as CSSProperties}>
                        {list.map((item, i) => {
                            const onWall = i < rows * 2
                            const side = onWall ? (i % 2 === 0 ? "l" : "r") : ends === 1 ? "e" : i === n - 2 ? "el" : "er"
                            const row = onWall ? Math.floor(i / 2) : rows
                            const img = item.image
                            const picture = img?.src ? (
                                <img srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt={img.alt || item.title || ""} loading="lazy" decoding="async" draggable={false} />
                            ) : (
                                <div className="lyr-lg-e" style={{ background: EMPTY[i % EMPTY.length] }} />
                            )
                            const common = { className: "lyr-lg-print", "data-side": side, style: { "--r": String(row) } as CSSProperties }
                            return item.link ? (
                                <a key={i} {...common} href={item.link} tabIndex={-1} aria-hidden="true">
                                    {picture}
                                </a>
                            ) : (
                                <div key={i} {...common}>
                                    {picture}
                                </div>
                            )
                        })}
                    </div>
                </div>
                {link && atEnd && (
                    <div className="lyr-lg-more">
                        <a href={link}>
                            {linkLabel}
                            <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
                                <path d="M3 8h10M9 4l4 4-4 4" />
                            </svg>
                        </a>
                    </div>
                )}
                {n > 0 && (
                    <div className="lyr-lg-cap">
                        {label(left, "lyr-lg-a")}
                        {middle ? (
                            label(middle, "lyr-lg-b")
                        ) : (
                            <div className="lyr-lg-b" key={"count" + shown}>
                                <span style={{ color: muted }}>
                                    {two(shown + 1)} of {two(rows + 1)}
                                </span>
                            </div>
                        )}
                        {label(right, "lyr-lg-c")}
                    </div>
                )}
            </div>
        </div>
    )
}

addPropertyControls(LongGallery, {
    items: {
        type: ControlType.Array,
        title: "Items",
        maxCount: 24,
        control: {
            type: ControlType.Object,
            controls: {
                image: { type: ControlType.ResponsiveImage, title: "Photo" },
                title: { type: ControlType.String, title: "Title", defaultValue: "" },
                detail: { type: ControlType.String, title: "Detail", defaultValue: "" },
                note: { type: ControlType.String, title: "Note", defaultValue: "" },
                link: { type: ControlType.Link, title: "Link" },
            },
        },
        defaultValue: ITEMS,
        description: "They hang in facing pairs, in order; with an odd number the last one hangs alone on the end wall.",
    },
    travel: {
        type: ControlType.Number,
        title: "Scroll per pair",
        defaultValue: 80,
        min: 30,
        max: 200,
        step: 5,
        unit: "vh",
        description: "How much scrolling it takes to walk past one pair of prints.",
    },
    imageWidth: {
        type: ControlType.String,
        title: "Image width",
        defaultValue: "40vw",
        description: "Roughly how wide a print is on screen, for example 40vw. Used to load the right image size.",
    },
    link: { type: ControlType.Link, title: "Link", description: "Shown at the end wall. Leave empty for no link." },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "View all" },
    preview: {
        type: ControlType.Number,
        title: "Canvas preview",
        defaultValue: 0,
        min: 0,
        max: 12,
        step: 0.5,
        description: "How far down the room the canvas shows, counted in pairs. 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" },
})

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.