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