Layr Components

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
0.8
80 px
#111418
#FFFFFF
20 px

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.