Components / Hero
Descent
A pinned hero photo; as you scroll the camera drops through each step's photo while a big height figure counts down to the ground.
Scroll insideFull screen
Props
#111418
#FFFFFF
Set in code: Steps. 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. Descent shows up in the Insert menu, and its controls appear in the right panel.
// Descent · Layr Components
//
// A photo pins under the navigation and, as the page scrolls, the camera drops
// through one photo after another while a large figure counts down between the
// steps. Ticks along the top show the step you are on, and each step's title and
// line of text change at the foot.
// Paste into a new code file in Framer. Place it in the page flow at full width
// with its height set to Fit; it is taller than the window and pins itself while
// it plays. No ancestor may clip overflow. A step without a photo shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, startTransition, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
type Step = { image?: Img; title?: string; note?: string; value?: number }
interface DescentProps {
items: Step[]
heading: string
unit: string
screens: number
topGap: number
sizesHint: string
back: string
page: string
fontFamily: string
radius: number
style?: CSSProperties
}
const ITEMS: Step[] = [
{ title: "From above", note: "The whole site in one frame, before anyone sets foot on it.", value: 120 },
{ title: "Closer in", note: "Every edge, corner and join, measured as we go.", value: 45 },
{ title: "On the ground", note: "The photos, the measurements and one clear plan.", value: 0 },
]
// Shown when a step has no photo yet.
const EMPTY = ["linear-gradient(165deg, #6b7a74, #2c3633)", "linear-gradient(165deg, #7d7466, #352f28)", "linear-gradient(165deg, #5f6b78, #262c33)", "linear-gradient(165deg, #74706a, #2f2d2a)"]
const SHEET = `
.lyr-dsc{position:relative;width:100%;height:calc(100vh - var(--dsc-top) + var(--dsc-extra));height:calc(100dvh - var(--dsc-top) + var(--dsc-extra));container-type:inline-size;color:#fff;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.lyr-dsc *{box-sizing:border-box}
.lyr-dsc-pin{--dsc-gap:16px;position:sticky;top:var(--dsc-top);width:100%;height:calc(100vh - var(--dsc-top));height:calc(100dvh - var(--dsc-top));min-height:300px}
.lyr-dsc[data-static="1"]{height:auto}
.lyr-dsc[data-static="1"] .lyr-dsc-pin{position:relative;top:auto;height:640px}
.lyr-dsc-stage{position:relative;width:100%;height:100%;border-radius:var(--dsc-radius) var(--dsc-radius) 0 0;clip-path:inset(0 round var(--dsc-radius) var(--dsc-radius) 0 0);background:var(--dsc-back);display:flex;flex-direction:column;justify-content:flex-end;isolation:isolate}
.lyr-dsc-layers{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;border-radius:var(--dsc-radius) var(--dsc-radius) 0 0}
.lyr-dsc-img{position:absolute;left:0;top:0;width:100%;height:100%;will-change:transform,opacity}
.lyr-dsc-img img,.lyr-dsc-img .lyr-dsc-e{position:absolute;left:0;top:0;width:100%;max-width:none;height:100%;object-fit:cover;display:block;user-select:none;-webkit-user-drag:none}
.lyr-dsc-top{position:absolute;left:0;top:0;width:100%;z-index:31;padding:22px 32px 70px;background:linear-gradient(180deg,rgba(8,10,10,.36),rgba(8,10,10,0));pointer-events:none}
.lyr-dsc-ticks{display:flex;gap:6px}
.lyr-dsc-ticks i{display:block;flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.3);overflow:hidden;position:relative}
.lyr-dsc-ticks b{position:absolute;left:0;top:0;width:100%;height:100%;background:#fff;transform-origin:0 50%;transform:scaleX(0)}
.lyr-dsc-foot{position:sticky;bottom:0;z-index:31;display:flex;align-items:flex-end;justify-content:space-between;gap:32px;padding:clamp(28px,9vh,72px) 32px calc(26px + var(--dsc-gap));background:linear-gradient(180deg,rgba(8,10,10,0),rgba(8,10,10,.64))}
.lyr-dsc-foot::after{content:"";position:absolute;left:0;right:0;bottom:0;height:var(--dsc-gap);background:var(--dsc-page);pointer-events:none}
.lyr-dsc-foot::before{content:"";position:absolute;left:0;right:0;bottom:var(--dsc-gap);height:var(--dsc-radius);pointer-events:none;background:radial-gradient(circle at 100% 0,transparent calc(var(--dsc-radius) - .6px),var(--dsc-page) var(--dsc-radius)) left bottom/var(--dsc-radius) var(--dsc-radius) no-repeat,radial-gradient(circle at 0 0,transparent calc(var(--dsc-radius) - .6px),var(--dsc-page) var(--dsc-radius)) right bottom/var(--dsc-radius) var(--dsc-radius) no-repeat}
.lyr-dsc[data-static="1"] .lyr-dsc-foot{position:relative}
.lyr-dsc-label{margin:0 0 8px;font-size:15px;line-height:1.3;font-weight:500;letter-spacing:-0.01em;color:rgba(255,255,255,.84)}
.lyr-dsc-alt{display:flex;align-items:flex-end;gap:10px}
.lyr-dsc-num{font-size:clamp(76px,9.4cqw,136px);line-height:.84;font-weight:500;letter-spacing:-0.06em;font-variant-numeric:tabular-nums}
.lyr-dsc-unit{font-size:clamp(24px,2.5cqw,36px);line-height:1;font-weight:500;letter-spacing:-0.04em;color:rgba(255,255,255,.78);padding-bottom:.12em}
.lyr-dsc-steps{position:relative;flex:0 1 420px;min-width:0;text-align:right}
.lyr-dsc-step{position:absolute;right:0;bottom:0;width:100%;opacity:0;transform:translateY(8px);transition:opacity .45s ease,transform .45s ease;pointer-events:none}
.lyr-dsc-step[data-on="1"]{position:relative;opacity:1;transform:none}
.lyr-dsc-step h3{margin:0 0 8px;font-size:clamp(24px,2.3cqw,32px);line-height:1.08;font-weight:500;letter-spacing:-0.035em}
.lyr-dsc-step p{margin:0;font-size:17px;line-height:1.45;letter-spacing:-0.012em;color:rgba(255,255,255,.88)}
@container (max-width:719px){
.lyr-dsc-top{padding:16px 16px 56px}
.lyr-dsc-foot{flex-direction:column;align-items:stretch;gap:12px;padding:clamp(20px,6.5vh,56px) 18px calc(20px + var(--dsc-gap))}
.lyr-dsc-num{font-size:68px}
.lyr-dsc-steps{flex:none;text-align:left}
.lyr-dsc-step{right:auto;left:0}
.lyr-dsc-step p{font-size:16px}
.lyr-dsc-label{font-size:14px;margin-bottom:6px}
}
@media (max-width:1199px){.lyr-dsc-pin{--dsc-gap:12px}}
@media (max-width:809px){.lyr-dsc-pin{--dsc-gap:8px}}
@media (prefers-reduced-motion:reduce){.lyr-dsc-step{transition:none}}
`
/**
* @framerIntrinsicWidth 1168
* @framerIntrinsicHeight 640
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function Descent(props: DescentProps) {
const {
items = ITEMS,
heading = "Height above the ground",
unit = "m",
screens = 0.8,
topGap = 80,
sizesHint = "100vw",
back = "#111418",
page = "#F4F4F1",
fontFamily = "Inter",
radius = 20,
} = props
const isStatic = useIsStaticRenderer()
const steps = (items || []).filter((s) => s && (s.title || s.note || s.image?.src))
const n = Math.max(1, steps.length)
const rootRef = useRef<HTMLDivElement>(null)
const pinRef = useRef<HTMLDivElement>(null)
const numRef = useRef<HTMLSpanElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const tickRefs = useRef<(HTMLElement | null)[]>([])
const [index, setIndex] = useState(0)
// Later photos are only requested as the scene approaches them.
const [loadTo, setLoadTo] = useState(1)
const heights = steps.map((s) => Number(s.value) || 0)
const heightsKey = heights.join(",")
const indexRef = useRef(0)
const loadRef = useRef(1)
useEffect(() => {
if (isStatic) return
const root = rootRef.current
if (!root || typeof IntersectionObserver === "undefined") return
const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
const hs = heightsKey ? heightsKey.split(",").map(Number) : []
let raf = 0
const paint = () => {
raf = 0
const pin = pinRef.current
if (!pin) return
const r = root.getBoundingClientRect()
const total = Math.max(1, r.height - pin.offsetHeight)
const p = Math.max(0, Math.min(1, (topGap - r.top) / total))
const f = p * (n - 1)
for (let i = 0; i < n; i++) {
const el = imgRefs.current[i]
if (el) {
const t = f - i
let scale = 1
let opacity = 1
if (t > 0 && i < n - 1) {
const k = Math.min(1, t)
scale = reduce ? 1 : 1 + k * k * 0.9
opacity = k < 0.45 ? 1 : Math.max(0, 1 - (k - 0.45) / 0.5)
} else if (t <= 0 && !reduce) {
// The photo still to come sits slightly wide, then settles as it arrives.
scale = 1 + Math.min(1, -t) * 0.06
}
el.style.transform = `scale(${scale.toFixed(4)})`
el.style.opacity = opacity.toFixed(3)
el.style.visibility = opacity <= 0.001 ? "hidden" : "visible"
}
const tick = tickRefs.current[i]
if (tick) {
const fill = n === 1 ? 1 : Math.max(0, Math.min(1, f - i + 1))
tick.style.transform = `scaleX(${fill.toFixed(4)})`
}
}
if (numRef.current && hs.length) {
const lo = Math.min(n - 1, Math.floor(f))
const hi = Math.min(n - 1, lo + 1)
const t = f - lo
const eased = t * t * (3 - 2 * t)
numRef.current.textContent = String(Math.round(hs[lo] + (hs[hi] - hs[lo]) * eased))
}
const next = Math.max(0, Math.min(n - 1, Math.round(f)))
if (next !== indexRef.current) {
indexRef.current = next
startTransition(() => setIndex(next))
}
const want = Math.min(n - 1, Math.floor(f + 0.15) + 1)
if (want > loadRef.current) {
loadRef.current = want
startTransition(() => setLoadTo(want))
}
}
const onScroll = () => {
if (!raf) raf = window.requestAnimationFrame(paint)
}
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
window.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("resize", onScroll)
onScroll()
} else {
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
}
})
io.observe(root)
return () => {
io.disconnect()
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
if (raf) window.cancelAnimationFrame(raf)
}
}, [isStatic, n, topGap, heightsKey])
const vars = {
"--dsc-back": back,
"--dsc-radius": `${radius}px`,
"--dsc-top": `${topGap}px`,
"--dsc-page": page,
"--dsc-extra": `${Math.round(Math.max(0, n - 1) * screens * 100)}vh`,
} as CSSProperties
const current = Math.min(index, n - 1)
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
return (
<div ref={rootRef} className="lyr-dsc" data-static={isStatic ? "1" : "0"} style={{ ...props.style, ...vars, fontFamily: font }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-dsc-pin" ref={pinRef}>
<div className="lyr-dsc-stage">
<div className="lyr-dsc-layers">
{steps.map((s, i) => (
<div
className="lyr-dsc-img"
key={i}
ref={(el) => {
imgRefs.current[i] = el
}}
style={{ zIndex: n - i }}
>
{i <= loadTo || isStatic ? (
s.image?.src ? (
<img
srcSet={s.image.srcSet}
sizes={sizesHint || "100vw"}
src={s.image.src}
alt={s.image.alt || s.title || ""}
loading="eager"
decoding="async"
draggable={false}
/>
) : (
<div className="lyr-dsc-e" style={{ background: EMPTY[i % EMPTY.length] }} />
)
) : null}
</div>
))}
</div>
<div className="lyr-dsc-top">
<div className="lyr-dsc-ticks" role="img" aria-label={`Step ${current + 1} of ${n}`}>
{steps.map((_, i) => (
<i key={i}>
<b
ref={(el) => {
tickRefs.current[i] = el
}}
style={i === 0 ? { transform: "scaleX(1)" } : undefined}
/>
</i>
))}
</div>
</div>
<div className="lyr-dsc-foot">
<div>
<p className="lyr-dsc-label">{heading}</p>
<div className="lyr-dsc-alt" aria-hidden="true">
<span className="lyr-dsc-num" ref={numRef}>
{heights[0] !== undefined ? Math.round(heights[0]) : 0}
</span>
<span className="lyr-dsc-unit">{unit}</span>
</div>
</div>
<div className="lyr-dsc-steps">
{steps.map((s, i) => (
<div className="lyr-dsc-step" key={i} data-on={i === current ? "1" : "0"} aria-hidden={i !== current}>
{s.title ? <h3>{s.title}</h3> : null}
{s.note ? <p>{s.note}</p> : null}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
addPropertyControls(Descent, {
items: {
type: ControlType.Array,
title: "Steps",
maxCount: 12,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Photo" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
note: { type: ControlType.String, title: "Text", defaultValue: "", displayTextArea: true },
value: { type: ControlType.Number, title: "Figure", defaultValue: 0, step: 1, description: "The number shown at this step. The counter runs between steps." },
},
},
defaultValue: ITEMS,
description: "One photo per step, in order. The camera drops through each one as the page scrolls.",
},
heading: { type: ControlType.String, title: "Label", defaultValue: "Height above the ground", description: "The small line above the number." },
unit: { type: ControlType.String, title: "Unit", defaultValue: "m" },
screens: {
type: ControlType.Number,
title: "Scroll per step",
defaultValue: 0.8,
min: 0.4,
max: 2,
step: 0.1,
description: "How many screens of scrolling each step takes.",
},
topGap: {
type: ControlType.Number,
title: "Top gap",
defaultValue: 80,
min: 0,
max: 200,
step: 1,
unit: "px",
description: "Space kept above the photo while it is pinned. Use the height of the navigation plus a little.",
},
sizesHint: {
type: ControlType.String,
title: "Image width",
defaultValue: "100vw",
description: "How wide the photo is on screen, as one value such as 100vw.",
},
back: { type: ControlType.Color, title: "Behind photos", defaultValue: "#111418" },
page: {
type: ControlType.Color,
title: "Page background",
defaultValue: "#F4F4F1",
description: "The colour of the page around the photo. It draws the gap and the rounded corners under the photo, so the first screen ends inside the window.",
},
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Type the family name used by the site's text styles." },
radius: { type: ControlType.Number, title: "Corner radius", defaultValue: 20, min: 0, max: 48, step: 1, unit: "px" },
})
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.