Components / Hero
Rack Focus
A hero of photos at different depths: one sharp, the rest blurred, and scrolling pulls focus from print to print along a lens distance scale.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
#FFFFFF
Set in code: Items. The preview fills these in with demo content.
Scroll inside the preview, or click a stop on the scale.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Rack Focus shows up in the Insert menu, and its controls appear in the right panel.
// Rack Focus · Layr Components
//
// A hero of photos hung at different depths. One print is sharp and the rest
// are out of focus, nearer or further away; scrolling pulls focus from one
// print to the next. Under it sit a heading and a lens distance scale whose
// marker follows the scroll; each stop on the scale jumps to its photo.
// Paste into a new code file in Framer. Place it at the top of the page at
// full width; it is taller than the window and pins itself while focus moves.
// 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 RackFocusProps {
items: Item[]
heading: string
subheading: string
hint: string
linkLabel: string
scale: boolean
shape: string
travel: number
topSpace: number
imageWidth: string
preview: number
fontFamily: string
ink: string
muted: string
line: string
accent: string
background: string
style?: CSSProperties
}
const ITEMS: Item[] = [
{ title: "Low tide", detail: "North coast", note: "2026" },
{ title: "Dry season", detail: "Inland", note: "2026" },
{ title: "Evening light", detail: "Hills", note: "2025" },
{ title: "Harbour, early", detail: "West coast", note: "2025" },
{ title: "Quiet rooms", detail: "Studio", note: "2024" },
{ title: "Pale morning", detail: "Dunes", note: "2024" },
]
// 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")
// Hold each print for a while, then pull focus to the next one.
function plateau(raw: number): number {
const i = Math.floor(raw)
const f = raw - i
const hold = 0.2
const u = clamp((f - hold) / (1 - 2 * hold), 0, 1)
return i + u * u * (3 - 2 * u)
}
// Where a print sits for each depth step. 0 is the print in focus, negative
// steps are nearer than the focus plane, positive steps are further away.
// x and y are percentages of the stage, measured from its centre.
const DEPTH = [-2, -1, 0, 1, 2, 3]
const WIDE = {
x: [96, 52, 4, -25, -38, -44],
y: [88, 46, 0, -24, -6, -30],
s: [1.7, 1.25, 1, 0.4, 0.25, 0.15],
}
const TALL = {
x: [56, 30, 0, -18, 22, -8],
y: [130, 74, 0, -41, -50, -62],
s: [1.45, 1.15, 1, 0.44, 0.27, 0.15],
}
const FADE = [0, 0.86, 1, 0.62, 0.42, 0]
function sample(values: number[], d: number, log = false): number {
const u = clamp(d, DEPTH[0], DEPTH[DEPTH.length - 1]) - DEPTH[0]
const i = Math.min(values.length - 2, Math.floor(u))
const f = u - i
if (log) return Math.exp(Math.log(values[i]) + (Math.log(values[i + 1]) - Math.log(values[i])) * f)
return values[i] + (values[i + 1] - values[i]) * f
}
// Depth of item i when the focus is at position p. The list wraps, so there
// is always something nearer and something further than the print in focus.
function depthOf(i: number, p: number, n: number): number {
if (n < 3) return i - p
const half = n / 2
return ((((i - p + half) % n) + n) % n) - half
}
function printVars(d: number, n: number): Record<string, string> {
const edge = n < 3 ? 1 : clamp((n / 2 - Math.abs(d)) / 0.5, 0, 1)
const k0 = clamp(1 - Math.abs(d) / 0.6, 0, 1)
const k = k0 * k0 * (3 - 2 * k0)
return {
"--x": sample(WIDE.x, d).toFixed(3),
"--y": sample(WIDE.y, d).toFixed(3),
"--s": sample(WIDE.s, d, true).toFixed(4),
"--xt": sample(TALL.x, d).toFixed(3),
"--yt": sample(TALL.y, d).toFixed(3),
"--st": sample(TALL.s, d, true).toFixed(4),
"--o": (sample(FADE, d) * edge).toFixed(3),
"--k": k.toFixed(3),
"--bo": (1 - Math.pow(k, 4)).toFixed(3),
"--z": String(Math.round(100 - d * 10)),
}
}
// Focus distances as engraved on a lens barrel, in metres. The last stop is
// always infinity. Spacing follows a real lens: wide apart close up, tight
// towards infinity. Past eight photos the stops are numbered.
const DISTANCES: Record<number, number[]> = {
2: [0.7, Infinity],
3: [0.7, 2, Infinity],
4: [0.7, 1.5, 4, Infinity],
5: [0.7, 1.2, 2, 5, Infinity],
6: [0.7, 1, 1.5, 3, 7, Infinity],
7: [0.7, 1, 1.5, 2, 3, 7, Infinity],
8: [0.7, 1, 1.5, 2, 3, 5, 10, Infinity],
}
function stops(n: number): { at: number; label: string }[] {
const d = DISTANCES[n]
if (!d) return Array.from({ length: n }, (_, i) => ({ at: n > 1 ? (i / (n - 1)) * 100 : 0, label: String(i + 1) }))
return d.map((v, i) => ({
at: v === Infinity ? 100 : (1 - d[0] / v) * 100,
label: v === Infinity ? "∞" : i === 0 ? `${v} m` : String(v),
}))
}
function markAt(marks: { at: number }[], p: number): number {
if (!marks.length) return 0
const max = marks.length - 1
if (max === 0) return marks[0].at
const c = Math.min(max, Math.max(0, p))
const i = Math.min(max - 1, Math.floor(c))
return marks[i].at + (marks[i + 1].at - marks[i].at) * (c - i)
}
function ratioOf(shape: string): number {
const s = String(shape).toLowerCase()
if (s.includes("4:5") || s.includes("portrait")) return 0.8
if (s.includes("1:1") || s.includes("square")) return 1
if (s.includes("16:9") || s.includes("wide")) return 16 / 9
return 1.5
}
const SHEET = `
.lyr-rf{position:relative;width:100%;background:var(--ground);color:var(--ink)}
.lyr-rf-pin{position:sticky;top:0;width:100%;height:100vh;height:100dvh;min-height:520px;box-sizing:border-box;padding-top:var(--top);display:flex;flex-direction:column;overflow:clip}
.lyr-rf[data-static="1"] .lyr-rf-pin{position:relative;height:760px}
.lyr-rf-stage{position:relative;flex:1 1 0;min-height:160px;padding:0 40px}
.lyr-rf-box{position:relative;width:100%;height:100%;container-type:size;isolation:isolate}
.lyr-rf-in{position:absolute;inset:0;--pw:min(62cqw,calc((100cqh - 64px)*var(--ratio)));--blur:max(7px,1.15cqw)}
.lyr-rf-p{position:absolute;left:50%;top:calc(50% - 22px);width:var(--pw);aspect-ratio:var(--ratio);display:block;border-radius:2px;text-decoration:none;pointer-events:none;opacity:var(--o);z-index:var(--z);will-change:transform,opacity;transform:translate3d(calc(-50% + var(--x)*1cqw),calc(-50% + var(--y)*1cqh),0) scale(var(--s))}
.lyr-rf-p[data-on="1"]{pointer-events:auto}
.lyr-rf-p img,.lyr-rf-p .lyr-rf-t{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;user-select:none;-webkit-user-drag:none}
.lyr-rf-b{filter:blur(var(--blur));opacity:var(--bo)}
.lyr-rf-s{opacity:var(--k)}
.lyr-rf-e{position:absolute;inset:0;border-radius:2px;background:rgba(255,255,255,.07)}
.lyr-rf-cap{position:absolute;left:calc(50% + 4cqw);top:calc(50% - 22px + var(--pw)/var(--ratio)/2 + 14px);width:var(--pw);transform:translateX(-50%);display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:14px;line-height:1.3;letter-spacing:-.005em;font-weight:400;z-index:200;font-variant-numeric:tabular-nums}
.lyr-rf-cap b{font-weight:500}
.lyr-rf-cap a{color:inherit;text-decoration:none}
.lyr-rf-cap a:hover{opacity:.68}
.lyr-rf-row{display:flex;gap:8px;min-width:0;animation:lyr-rf-in .28s ease both}
.lyr-rf-row span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--muted)}
.lyr-rf-go{white-space:nowrap;display:inline-flex;align-items:center;gap:6px;transition:opacity .2s}
.lyr-rf-count{white-space:nowrap;color:var(--muted)}
.lyr-rf-foot{position:relative;z-index:2;padding:0 40px 26px;background:linear-gradient(0deg,var(--ground) 0%,color-mix(in srgb,var(--ground) 86%,transparent) 34%,color-mix(in srgb,var(--ground) 0%,transparent) 100%)}
.lyr-rf-head{display:flex;align-items:flex-end;gap:24px;padding:18px 0 26px}
.lyr-rf-h{flex:1 1 auto;min-width:0;margin:0;font-size:clamp(40px,calc(4.4vw + 22px),78px);font-weight:400;line-height:1;letter-spacing:-.04em;text-wrap:balance}
.lyr-rf-h span{display:block}
.lyr-rf-h span+span{color:var(--muted)}
.lyr-rf-hint{flex:none;margin:0;font-size:14px;line-height:1.45;letter-spacing:-.005em;color:var(--muted);white-space:nowrap}
.lyr-rf-sc{position:relative;width:100%;height:46px;container-type:inline-size;font-size:13px;line-height:1;letter-spacing:-.005em;font-variant-numeric:tabular-nums;color:var(--muted)}
.lyr-rf-rule{position:absolute;left:0;right:0;top:14px;height:1px;background:var(--line)}
.lyr-rf-stop{position:absolute;top:0;height:46px;padding:0;margin:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;transition:color .25s}
.lyr-rf-stop i{display:block;width:1px;height:9px;margin-top:10px;opacity:.75;background:currentColor}
.lyr-rf-stop span{margin-top:9px;white-space:nowrap}
.lyr-rf-stop:first-of-type{transform:none;align-items:flex-start}
.lyr-rf-stop:last-of-type{transform:translateX(-100%);align-items:flex-end}
.lyr-rf-stop:hover span{opacity:.68}
.lyr-rf-stop:focus-visible{outline:1px solid currentColor;outline-offset:4px}
.lyr-rf-mark{position:absolute;left:0;top:2px;width:1px;height:17px;background:var(--accent);transform:translateX(calc(var(--m)*1cqw - .5px));will-change:transform}
@keyframes lyr-rf-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@container (max-aspect-ratio:13/10){
.lyr-rf-in{--pw:min(78cqw,calc((100cqh - 64px)*var(--ratio)))}
}
@container (max-aspect-ratio:1/1){
.lyr-rf-in{--pw:min(92cqw,calc((100cqh - 64px)*var(--ratio)))}
.lyr-rf-p{transform:translate3d(calc(-50% + var(--xt)*1cqw),calc(-50% + var(--yt)*1cqh),0) scale(var(--st))}
.lyr-rf-cap{left:50%}
}
@media (max-width:600px){
.lyr-rf-pin{padding-top:calc(var(--top) * .76);min-height:480px}
.lyr-rf-stage{padding:0 16px}
.lyr-rf-foot{padding:0 16px 16px}
.lyr-rf-head{padding:12px 0 18px}
.lyr-rf-hint{display:none}
}
@media (prefers-reduced-motion:reduce){.lyr-rf-row{animation:none}}
`
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 760
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function RackFocus(props: RackFocusProps) {
const {
items = ITEMS,
heading = "Places, people",
subheading = "and the light between.",
hint = "Scroll to pull focus",
linkLabel = "View project",
scale = true,
shape = "3:2 landscape",
travel = 55,
topSpace = 84,
imageWidth = "62vw",
preview = 0,
fontFamily = "Inter",
ink = "#ECE9E2",
muted = "#8E8B84",
line = "rgba(236,233,226,0.38)",
accent = "#F0C445",
background = "#0A0A0A",
} = 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 prints = useRef<(HTMLAnchorElement | null)[]>([])
const mark = useRef<HTMLDivElement>(null)
const pRef = useRef(0)
const activeRef = useRef(0)
const jump = useRef<(i: number) => void>(() => {})
const n = list.length
const marks = stops(n)
// The scroll track: how far the tall root has been scrolled, held at each
// print for a moment and eased between them.
useEffect(() => {
const host = root.current
if (!host || isStatic || n < 1) return
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const scaleMarks = stops(n)
let raf = 0
let last = 0
let lastScroll = 0
let top = 0
let range = 0
let t = 0
const paint = (p: number) => {
pRef.current = p
for (let i = 0; i < n; i++) {
const el = prints.current[i]
if (!el) continue
const d = depthOf(i, p, n)
const vars = printVars(d, n)
for (const name in vars) el.style.setProperty(name, vars[name])
el.dataset.on = Math.abs(d) < 0.5 ? "1" : "0"
}
if (mark.current) mark.current.style.setProperty("--m", markAt(scaleMarks, p).toFixed(3))
const now = ((Math.round(p) % n) + n) % n
if (now !== activeRef.current) {
activeRef.current = now
startTransition(() => setActive(now))
}
}
const measure = () => {
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 tick = (now: number) => {
raf = 0
const idle = now - lastScroll > 170
let goal = plateau(t * (n - 1))
if (idle || reduced) goal = Math.round(goal)
const dt = Math.min(64, Math.max(1, now - last))
last = now
let p = reduced ? goal : pRef.current + (goal - pRef.current) * (1 - Math.exp(-dt / 140))
if (Math.abs(goal - p) < 0.0004) 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
measure()
if (t !== before) {
lastScroll = performance.now()
wake()
}
}
const onResize = () => {
measure()
wake()
}
jump.current = (index: number) => {
measure()
window.scrollTo({ top: top + (n > 1 ? index / (n - 1) : 0) * Math.max(0, range), behavior: reduced ? "auto" : "smooth" })
}
measure()
paint(Math.round(t * (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)
jump.current = () => {}
}
}, [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 ratio = ratioOf(shape)
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, "--accent": accent, "--ground": background, "--top": `${topSpace}px` } as CSSProperties
const hasHead = Boolean(heading || subheading || hint)
return (
<div ref={root} className="lyr-rf" data-static={isStatic ? "1" : "0"} style={{ ...props.style, ...vars, height, fontFamily: font }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-rf-pin">
<div className="lyr-rf-stage">
<div className="lyr-rf-box">
<div className="lyr-rf-in" style={{ "--ratio": String(ratio) } as CSSProperties}>
{n === 0 &&
[1, 0, -1].map((d) => (
<div key={d} className="lyr-rf-p" style={printVars(d, 3) as CSSProperties}>
<div className="lyr-rf-e" />
</div>
))}
{list.map((item, i) => {
const d = depthOf(i, p, n)
const img = item.image
const tone = { background: EMPTY[i % EMPTY.length] }
return (
<a
key={i}
ref={(el) => {
prints.current[i] = el
}}
className="lyr-rf-p"
href={item.link || undefined}
data-on={Math.abs(d) < 0.5 ? "1" : "0"}
tabIndex={-1}
aria-hidden="true"
style={printVars(d, n) as CSSProperties}
>
{img?.src ? (
<>
<img className="lyr-rf-b" srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt="" loading={i === 0 ? "eager" : "lazy"} decoding="async" draggable={false} />
<img className="lyr-rf-s" srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt={img.alt || item.title || ""} loading={i === 0 ? "eager" : "lazy"} decoding="async" draggable={false} />
</>
) : (
<>
<span className="lyr-rf-t lyr-rf-b" style={tone} />
<span className="lyr-rf-t lyr-rf-s" style={tone} />
</>
)}
</a>
)
})}
{current && (
<div className="lyr-rf-cap">
<div className="lyr-rf-row" key={shown}>
<b>{current.title}</b>
<span>{[current.detail, current.note].filter(Boolean).join(", ")}</span>
</div>
{current.link ? (
<a className="lyr-rf-go" href={current.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>
) : (
<span className="lyr-rf-count">
{two(shown + 1)} of {two(n)}
</span>
)}
</div>
)}
</div>
</div>
</div>
{(hasHead || (scale && n > 0)) && (
<div className="lyr-rf-foot">
{hasHead && (
<div className="lyr-rf-head">
<h2 className="lyr-rf-h">
{heading && <span>{heading}</span>}
{subheading && <span>{subheading}</span>}
</h2>
{hint && <p className="lyr-rf-hint">{hint}</p>}
</div>
)}
{scale && n > 0 && (
<div className="lyr-rf-sc">
<div className="lyr-rf-rule" />
{marks.map((m, i) => (
<button
key={i}
type="button"
className="lyr-rf-stop"
style={{ left: `${m.at}%`, color: i === shown ? accent : undefined }}
aria-label={`Show ${list[i]?.title || "photo " + (i + 1)}`}
aria-current={i === shown ? "true" : undefined}
onClick={() => jump.current(i)}
>
<i />
<span>{m.label}</span>
</button>
))}
<div ref={mark} className="lyr-rf-mark" style={{ "--m": markAt(marks, p).toFixed(3) } as CSSProperties} />
</div>
)}
</div>
)}
</div>
</div>
)
}
addPropertyControls(RackFocus, {
items: {
type: ControlType.Array,
title: "Items",
maxCount: 12,
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: "One print per item, in the order focus moves through them.",
},
heading: { type: ControlType.String, title: "Heading", defaultValue: "Places, people" },
subheading: { type: ControlType.String, title: "Second line", defaultValue: "and the light between.", description: "Set in the muted colour under the heading." },
hint: { type: ControlType.String, title: "Hint", defaultValue: "Scroll to pull focus", description: "Small text at the right of the heading. Hidden on phones." },
linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "View project", description: "Shown under the print in focus when its item has a link." },
scale: { type: ControlType.Boolean, title: "Distance scale", defaultValue: true, description: "A lens distance scale under the heading. Its marker follows the scroll and each stop jumps to its photo." },
shape: {
type: ControlType.Enum,
title: "Print shape",
options: ["3:2 landscape", "4:5 portrait", "1:1 square", "16:9 wide"],
optionTitles: ["3:2 landscape", "4:5 portrait", "1:1 square", "16:9 wide"],
defaultValue: "3:2 landscape",
description: "Every photo is cropped to this shape, so any photo works.",
},
travel: {
type: ControlType.Number,
title: "Scroll per photo",
defaultValue: 55,
min: 30,
max: 160,
step: 5,
unit: "vh",
description: "How much scrolling it takes to pull focus from one print to the next.",
},
topSpace: {
type: ControlType.Number,
title: "Top space",
defaultValue: 84,
min: 0,
max: 200,
step: 4,
unit: "px",
description: "Room above the prints for a fixed navigation bar. About three quarters of it on phones.",
},
imageWidth: {
type: ControlType.String,
title: "Image width",
defaultValue: "62vw",
description: "How wide the sharp print is on screen, for example 62vw. 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 is in focus on the canvas. 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: "Scale line", defaultValue: "rgba(236,233,226,0.38)" },
accent: { type: ControlType.Color, title: "Marker colour", defaultValue: "#F0C445" },
background: { type: ControlType.Color, title: "Background", defaultValue: "#0A0A0A", description: "Also the shade behind the heading." },
})
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.