Components / Hero
Lift
A pinned hero that rides down a building floor by floor: the photo travels like a lift car, a big numeral rolls and a strip of levels follows.
Scroll insideFull screen
Props
#0E0E0D
#A3A3A0
#6E6E6B
#E6E5E1
Set in code: Levels. The preview fills these in with demo content.
Scroll inside the preview, or click a level in the strip.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Lift shows up in the Insert menu, and its controls appear in the right panel.
// Lift · Layr Components
//
// A pinned hero that rides down a building floor by floor. Scrolling moves the
// photo like a lift car, a large numeral rolls to the next level, and a strip of
// levels underneath follows; each level in the strip jumps the ride there.
// With reduced motion, or Pin on scroll off, the levels show as a plain list.
// 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 it rides, so no
// ancestor may clip overflow. A level without a photo shows a plain tone.
import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
type Level = { numeral?: string; name?: string; shortName?: string; line?: string; image?: Img; link?: string }
interface LiftProps {
items: Level[]
linkLabel: string
headline: string
headlineQuiet: string
headingTag: string
hint: string
topGap: number
edge: number
step: number
slab: number
numeralWidth: number
sizes: string
pin: boolean
radius: number
fontFamily: string
ink: string
quiet: string
bodyColor: string
hairline: string
style?: CSSProperties
}
const LEVELS: Level[] = [
{ numeral: "5", name: "The roof", shortName: "Roof", line: "A long table under open sky, best at the end of the day." },
{ numeral: "4", name: "The studios", shortName: "Studios", line: "Quiet rooms with tall windows and north light." },
{ numeral: "3", name: "The library", shortName: "Library", line: "Books, deep chairs and a fire in winter." },
{ numeral: "2", name: "The kitchen", shortName: "Kitchen", line: "One shared table, and breakfast until noon." },
{ numeral: "1", name: "The garden", shortName: "Garden", line: "Tall trees, a pool and somewhere to read." },
{ numeral: "0", name: "Street level", shortName: "Street", line: "The front door, the café and the way in." },
]
// Shown in the car when a level has no photo yet.
const EMPTY = ["linear-gradient(170deg, #d9d3c8, #a39b8e)", "linear-gradient(170deg, #cdd0cb, #8e958f)", "linear-gradient(170deg, #d7cec6, #9a8f87)"]
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
// Every level is drawn in the same pinned stage. Each level's parts sit in the same grid cells
// (the level itself is display: contents), so the numerals and photos overlap and roll past each
// other as --lf-p, the ride's position counted in levels, changes.
const STYLES = `
.lyr-lift{position:relative;width:100%;isolation:isolate;-webkit-font-smoothing:antialiased;font-synthesis:none;height:calc(100vh - var(--lf-top) + (var(--lf-n) - 1) * var(--lf-step));height:calc(100svh - var(--lf-top) + (var(--lf-n) - 1) * var(--lf-step))}
.lyr-lift *{box-sizing:border-box}
.lyr-lift-pin{position:sticky;top:var(--lf-top);height:calc(100vh - var(--lf-top));height:calc(100svh - var(--lf-top));container-type:size}
.lyr-lift-stage{position:absolute;inset:0;padding:12px var(--lf-edge) var(--lf-edge);display:grid;grid-template-columns:minmax(0,clamp(330px,35cqw,540px)) minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto;grid-template-areas:"head car" "mid car" "strip car";column-gap:clamp(28px,4.4cqw,72px);--lf-fs:clamp(140px,min(26cqw,43cqh),400px)}
.lyr-lift-level{display:contents}
.lyr-lift-head{grid-area:head;margin:0;padding-top:clamp(4px,2cqh,20px);font-weight:400;font-size:clamp(32px,min(4.1cqw,8cqh),60px);line-height:1.02;letter-spacing:-0.042em;color:var(--lf-ink);text-wrap:balance}
.lyr-lift-head span{display:block;color:var(--lf-quiet)}
.lyr-lift-mid{grid-area:mid;align-self:end;display:flex;align-items:flex-end;gap:clamp(14px,2cqw,30px);min-width:0;pointer-events:none}
.lyr-lift-numwin{flex:none;position:relative;overflow:hidden;font-size:var(--lf-fs);height:.77em;width:calc(var(--lf-numw) * 1em);line-height:.77;letter-spacing:-0.06em;color:var(--lf-ink);font-weight:400;white-space:nowrap}
.lyr-lift-num{display:block;height:100%;transform:translate3d(0,calc((var(--lf-k) - var(--lf-p)) * 100%),0);will-change:transform;font-variant-numeric:lining-nums}
.lyr-lift-g{display:block;transform:translateY(-.02em)}
.lyr-lift-info{flex:1;min-width:0;padding-bottom:.2em;opacity:0;transition:opacity .12s ease;pointer-events:none}
.lyr-lift-level[data-on="1"] .lyr-lift-info{opacity:1;pointer-events:auto;transition:opacity .34s ease .16s}
.lyr-lift-name{display:block;margin:0;font-size:clamp(20px,1.9cqw,27px);line-height:1.15;letter-spacing:-0.028em;color:var(--lf-ink);text-decoration:none;font-weight:400}
.lyr-lift-line{margin:7px 0 0;font-size:15px;line-height:1.45;letter-spacing:-0.005em;color:var(--lf-body);max-width:27ch}
.lyr-lift-link{display:inline-flex;align-items:center;gap:8px;margin-top:18px;padding-bottom:3px;border-bottom:1px solid currentColor;font-size:15px;line-height:1.3;color:var(--lf-ink);text-decoration:none;transition:opacity .2s}
.lyr-lift-link:hover,a.lyr-lift-name:hover{opacity:.68}
.lyr-lift-link svg,.lyr-lift-hint svg{width:14px;height:14px;flex:none}
.lyr-lift-hint{display:flex;align-items:center;gap:8px;margin-top:16px;font-size:14px;line-height:1.3;color:var(--lf-quiet)}
.lyr-lift-strip{grid-area:strip;position:relative;height:60px;margin-top:clamp(18px,4.2cqh,44px)}
.lyr-lift-base{position:absolute;left:0;right:0;top:0;height:1px;background:var(--lf-hair)}
.lyr-lift-ind{position:absolute;left:0;top:0;height:1px;width:calc(100% / var(--lf-n));background:var(--lf-ink);transform:translate3d(calc(var(--lf-p) * 100%),0,0);will-change:transform}
.lyr-lift-slot{position:absolute;top:0;left:calc(var(--lf-k) * 100% / var(--lf-n));width:calc(100% / var(--lf-n));height:100%;margin:0;padding:13px 6px 0 0;border:0;background:none;text-align:left;font:inherit;font-size:14px;line-height:1.35;letter-spacing:-0.005em;color:var(--lf-quiet);cursor:pointer;transition:color .3s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;-webkit-tap-highlight-color:transparent}
.lyr-lift-sn{display:block}
.lyr-lift-slot[data-on="1"],.lyr-lift-slot:hover{color:var(--lf-ink)}
.lyr-lift-slot:focus-visible,.lyr-lift-link:focus-visible,.lyr-lift-name:focus-visible{outline:1px solid var(--lf-ink);outline-offset:3px}
.lyr-lift-car{grid-area:car;position:relative;min-width:0;min-height:0;overflow:hidden;border-radius:var(--lf-r);pointer-events:none}
.lyr-lift-photo{position:absolute;inset:0;transform:translate3d(0,calc((var(--lf-k) - var(--lf-p)) * (100% + var(--lf-slab))),0);will-change:transform;visibility:hidden;background:var(--lf-hair)}
.lyr-lift-level[data-near="1"] .lyr-lift-photo{visibility:visible}
.lyr-lift-photo img,.lyr-lift-empty{display:block;width:100%;height:100%;object-fit:cover}
@container (max-width:899px){
.lyr-lift-stage{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto auto;grid-template-areas:"head" "car" "mid" "strip";--lf-fs:clamp(84px,min(25cqw,15cqh),170px)}
.lyr-lift-head{padding:0 0 clamp(14px,2.6cqh,28px);font-size:clamp(30px,min(8.6cqw,6cqh),64px)}
.lyr-lift-head span{display:inline}
.lyr-lift-mid{align-self:auto;align-items:flex-start;height:calc(var(--lf-fs) * .77);margin-top:clamp(14px,2.6cqh,26px)}
.lyr-lift-info{padding:calc(var(--lf-fs) * .09) 0 0}
.lyr-lift-name{font-size:clamp(19px,3.2cqw,26px)}
.lyr-lift-line{font-size:14px;max-width:none;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.lyr-lift-link,.lyr-lift-hint{display:none}
.lyr-lift-strip{height:52px;margin-top:clamp(12px,2.2cqh,22px)}
.lyr-lift-slot{font-size:13px;padding-top:11px}
}
.lyr-lift-static.lyr-lift{height:760px}
.lyr-lift-static .lyr-lift-pin{position:relative;top:0;height:760px}
`
// The plain list: each level gets its own row with its numeral, text and photo.
const FLAT = (p: string) => `
${p}.lyr-lift{height:auto!important}
${p}.lyr-lift .lyr-lift-pin{position:static;height:auto!important;container-type:inline-size}
${p}.lyr-lift .lyr-lift-stage{position:static;display:block;padding:0 var(--lf-edge);--lf-fs:clamp(96px,16vw,220px)}
${p}.lyr-lift .lyr-lift-head{padding:0 0 40px}
${p}.lyr-lift .lyr-lift-level{display:grid;grid-template-columns:minmax(0,clamp(330px,35cqw,540px)) minmax(0,1fr);grid-template-areas:"mid car";column-gap:clamp(28px,4.4cqw,72px);padding-bottom:clamp(40px,6vw,72px)}
${p}.lyr-lift .lyr-lift-strip,${p}.lyr-lift .lyr-lift-hint{display:none}
${p}.lyr-lift .lyr-lift-mid{align-self:start;height:auto;pointer-events:auto}
${p}.lyr-lift .lyr-lift-num,${p}.lyr-lift .lyr-lift-photo{transform:none}
${p}.lyr-lift .lyr-lift-info{opacity:1;pointer-events:auto}
${p}.lyr-lift .lyr-lift-link{display:inline-flex}
${p}.lyr-lift .lyr-lift-car{aspect-ratio:3/2;pointer-events:auto}
${p}.lyr-lift .lyr-lift-photo{visibility:visible}
@container (max-width:899px){${p}.lyr-lift .lyr-lift-level{grid-template-columns:minmax(0,1fr);grid-template-areas:"car" "mid"}}
`
const SHEET = STYLES + FLAT(".lyr-lift-flat") + `@media (prefers-reduced-motion:reduce){${FLAT("")}}`
const Arrow = ({ down }: { down?: boolean }) => (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={down ? "M8 2.5v11M3.5 9 8 13.5 12.5 9" : "M2.5 8h11M9 3.5 13.5 8 9 12.5"} />
</svg>
)
const REDUCE = "(prefers-reduced-motion: reduce)"
const watchReduce = (change: () => void) => {
const mq = window.matchMedia(REDUCE)
mq.addEventListener("change", change)
return () => mq.removeEventListener("change", change)
}
const readReduce = () => window.matchMedia(REDUCE).matches
const serverReduce = () => false
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 760
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function Lift(props: LiftProps) {
const {
items = LEVELS,
linkLabel = "See this level",
headline = "Start at the top,",
headlineQuiet = "and work your way down.",
headingTag = "h1",
hint = "Scroll to ride down",
topGap = 76,
edge = 40,
step = 520,
slab = 14,
numeralWidth = 0.62,
sizes = "62vw",
pin = true,
radius = 0,
fontFamily = "Inter",
ink = "#1C1B1A",
quiet = "#A09D97",
bodyColor = "#5B5853",
hairline = "rgba(28,27,26,0.14)",
style,
} = props
const list = Array.isArray(items) ? items : []
const n = Math.max(1, list.length)
const isStatic = useIsStaticRenderer()
const reduce = useSyncExternalStore(watchReduce, readReduce, serverReduce)
const rootRef = useRef<HTMLDivElement>(null)
const flat = !pin
// Photos load as the ride nears them: the first at once, the next one or two soon after.
const [armedUpTo, setArmedUpTo] = useState(0)
const armAll = flat || reduce || isStatic
useEffect(() => {
const root = rootRef.current
if (!root || isStatic || flat || typeof window === "undefined") return
if (window.matchMedia(REDUCE).matches) return
const pinEl = root.querySelector(".lyr-lift-pin") as HTMLElement | null
const marks = Array.from(root.querySelectorAll<HTMLElement>("[data-k]"))
let shown = 0
let goal = 0
let raf = 0
let last = 0
let moved = 0
let lastOn = -1
let armed = 0
const measure = () => {
const box = root.getBoundingClientRect()
if (box.height < 2 || !pinEl) return
const travel = box.height - pinEl.offsetHeight
goal = travel > 4 ? clamp01((topGap - box.top) / travel) * (n - 1) : 0
}
const paint = () => {
root.style.setProperty("--lf-p", shown.toFixed(4))
const on = Math.round(shown)
for (const el of marks) {
const k = Number(el.dataset.k) || 0
const isOn = k === on ? "1" : "0"
const near = Math.abs(k - shown) < 1.02 ? "1" : "0"
if (el.dataset.on !== isOn) el.dataset.on = isOn
if (el.dataset.near !== near) el.dataset.near = near
}
if (on !== lastOn && root.getBoundingClientRect().width > 0) {
lastOn = on
// Tells the page which level the ride is on, for a level badge in the navigation.
const level = root.querySelector<HTMLElement>(`.lyr-lift-level[data-k="${on}"]`)
window.dispatchEvent(new CustomEvent("lyr-lift-level", { detail: { index: on, numeral: level?.dataset.numeral || "", name: level?.dataset.name || "" } }))
}
// The next photo is always ready; once the ride moves, the one after it too.
const want = Math.min(n - 1, Math.floor(shown) + (shown > 0.05 ? 2 : 1))
if (want > armed) {
armed = want
setArmedUpTo((a) => Math.max(a, want))
}
}
// The shown position eases toward the scroll position and, once the scroll rests,
// settles on the nearest level.
const tick = (now: number) => {
const dt = Math.min(64, now - (last || now))
last = now
const idle = now - moved > 170
const target = idle ? Math.round(goal) : goal
shown += (target - shown) * (1 - Math.exp(-dt / 120))
if (Math.abs(target - shown) < 0.0006) shown = target
paint()
raf = shown !== target || !idle ? requestAnimationFrame(tick) : 0
if (!raf) last = 0
}
const onScroll = () => {
measure()
moved = performance.now()
if (!raf) raf = requestAnimationFrame(tick)
}
window.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("resize", onScroll)
onScroll()
// After the page has loaded, fetch one more photo ahead; the rest wait for the ride.
let timer = 0
const later = () => {
timer = window.setTimeout(() => setArmedUpTo((a) => Math.max(a, 2)), 1200)
}
if (document.readyState === "complete") later()
else window.addEventListener("load", later, { once: true })
return () => {
window.clearTimeout(timer)
window.removeEventListener("load", later)
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
if (raf) cancelAnimationFrame(raf)
}
}, [isStatic, flat, n, topGap])
const go = (k: number) => {
setArmedUpTo((a) => Math.max(a, k))
const root = rootRef.current
const pinEl = root?.querySelector(".lyr-lift-pin") as HTMLElement | null
if (!root || !pinEl) return
const box = root.getBoundingClientRect()
if (box.height < 2) return
const travel = box.height - pinEl.offsetHeight
const y = window.scrollY + box.top - topGap + (n > 1 ? (k / (n - 1)) * travel : 0)
window.scrollTo({ top: Math.round(y) + 1, behavior: "smooth" })
}
const vars = {
"--lf-n": n,
"--lf-p": 0,
"--lf-top": `${topGap}px`,
"--lf-edge": `min(${edge}px, 5cqw)`,
"--lf-step": `${step}px`,
"--lf-slab": `${slab}px`,
"--lf-numw": numeralWidth,
"--lf-r": `${radius}px`,
"--lf-ink": ink,
"--lf-quiet": quiet,
"--lf-body": bodyColor,
"--lf-hair": hairline,
fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
} as CSSProperties
const tag = String(headingTag).toLowerCase()
const Head = tag.includes("h1") ? "h1" : tag.includes("h2") ? "h2" : "p"
const levels = list.map((item, k) => {
const label = String(item?.numeral ?? "").trim()
const title = String(item?.name ?? "").trim()
const short = String(item?.shortName ?? "").trim() || title
return { item: item || {}, k, label, title, short }
})
return (
<div ref={rootRef} className={"lyr-lift" + (flat ? " lyr-lift-flat" : "") + (isStatic ? " lyr-lift-static" : "")} style={{ ...style, ...vars }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-lift-pin">
<div className="lyr-lift-stage">
<Head className="lyr-lift-head">
{headline} <span>{headlineQuiet}</span>
</Head>
{levels.map(({ item, k, label, title, short }) => (
<div key={k} className="lyr-lift-level" data-k={k} data-on={k === 0 ? "1" : "0"} data-near={k === 0 ? "1" : "0"} data-numeral={label} data-name={short} style={{ "--lf-k": k } as CSSProperties}>
<div className="lyr-lift-mid">
<div className="lyr-lift-numwin" aria-hidden="true">
<span className="lyr-lift-num">
<span className="lyr-lift-g">{label}</span>
</span>
</div>
<div className="lyr-lift-info">
{item.link ? (
<Link href={item.link}>
<a className="lyr-lift-name">{title}</a>
</Link>
) : (
<p className="lyr-lift-name">{title}</p>
)}
{String(item.line || "").trim() && <p className="lyr-lift-line">{item.line}</p>}
{item.link && linkLabel.trim() && (
<Link href={item.link}>
<a className="lyr-lift-link">
{linkLabel} <Arrow />
</a>
</Link>
)}
{k === 0 && hint.trim() && (
<div className="lyr-lift-hint">
{hint} <Arrow down />
</div>
)}
</div>
</div>
<div className="lyr-lift-car">
<div className="lyr-lift-photo">
{item.image?.src ? (
(armAll || k <= armedUpTo) && (
<img
loading={k === 0 ? "eager" : "lazy"}
decoding={k === 0 ? "sync" : "async"}
srcSet={item.image.srcSet}
sizes={sizes}
src={item.image.src}
alt={item.image.alt || `${title}, level ${label}`}
/>
)
) : (
<div className="lyr-lift-empty" role="img" aria-label={`${title}, level ${label}`} style={{ background: EMPTY[k % EMPTY.length] }} />
)}
</div>
</div>
</div>
))}
<div className="lyr-lift-strip">
<div className="lyr-lift-base" />
<div className="lyr-lift-ind" />
{levels.map(({ k, label, title, short }) => (
<button key={k} type="button" className="lyr-lift-slot" data-k={k} data-on={k === 0 ? "1" : "0"} style={{ "--lf-k": k } as CSSProperties} onClick={() => go(k)} aria-label={`Level ${label}, ${title}`}>
<span className="lyr-lift-sn">{label}</span>
{short}
</button>
))}
</div>
</div>
</div>
</div>
)
}
addPropertyControls(Lift, {
items: {
type: ControlType.Array,
title: "Levels",
maxCount: 12,
control: {
type: ControlType.Object,
controls: {
numeral: { type: ControlType.String, title: "Numeral", defaultValue: "", description: "The large level number. A letter such as G also works." },
name: { type: ControlType.String, title: "Name", defaultValue: "" },
shortName: { type: ControlType.String, title: "Short name", defaultValue: "", description: "One word for the strip of levels." },
line: { type: ControlType.String, title: "Line", defaultValue: "", displayTextArea: true },
image: { type: ControlType.ResponsiveImage, title: "Photo" },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: LEVELS,
description: "One per level, from the top of the ride to the bottom.",
},
linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "See this level", description: "Shown under a level that has a link." },
headline: { type: ControlType.String, title: "Headline", defaultValue: "Start at the top," },
headlineQuiet: { type: ControlType.String, title: "Headline, second tone", defaultValue: "and work your way down." },
headingTag: { type: ControlType.Enum, title: "Headline tag", options: ["h1", "h2", "p"], optionTitles: ["H1", "H2", "P"], defaultValue: "h1" },
hint: { type: ControlType.String, title: "Scroll hint", defaultValue: "Scroll to ride down" },
pin: { type: ControlType.Boolean, title: "Pin on scroll", defaultValue: true, description: "Off shows the levels as a plain list." },
step: { type: ControlType.Number, title: "Scroll per level", defaultValue: 520, min: 200, max: 1200, step: 10, unit: "px" },
topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 76, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above the scene." },
edge: { type: ControlType.Number, title: "Side gap", defaultValue: 40, min: 0, max: 120, step: 1, unit: "px", description: "Shrinks to 5% of the width on narrow screens." },
slab: { type: ControlType.Number, title: "Gap between photos", defaultValue: 14, min: 0, max: 80, step: 1, unit: "px" },
numeralWidth: {
type: ControlType.Number,
title: "Numeral width",
defaultValue: 0.62,
min: 0.3,
max: 2,
step: 0.01,
unit: "em",
description: "Room kept for the large numeral, so the text beside it never moves. Use about 0.8 for a letter, 1.2 for two digits.",
},
radius: { type: ControlType.Number, title: "Corner radius", defaultValue: 0, min: 0, max: 60, step: 1, unit: "px" },
sizes: { type: ControlType.String, title: "Image width", defaultValue: "62vw", description: "How wide the photo is drawn, as one length such as 62vw." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#1C1B1A" },
quiet: { type: ControlType.Color, title: "Quiet", defaultValue: "#A09D97" },
bodyColor: { type: ControlType.Color, title: "Body text", defaultValue: "#5B5853" },
hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(28,27,26,0.14)" },
})
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.