Layr Components

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
520 px
0 px
40 px
14 px
0.62 em
0 px
#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.