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