Layr Components

Components / Numbers and data

Funnel

One photo cut into funnel tiers with a number per stage; as you scroll the lower tiers widen and the numbers switch from before to after.

Scroll insideFull screen
Props
84%
58%
90 vh
1
#0E0E0D
#6E6E6B
#E6E5E1
#F5F4F1
#FF4A1C

Set in code: Photo. 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. Funnel shows up in the Insert menu, and its controls appear in the right panel.
// Funnel · Layr Components
//
// One photo cut into tiers, one tier per stage, with each stage's number beside
// it. As the page scrolls the lower tiers widen (the same audience, more buyers)
// and the numbers change from before to after once the move has finished.
// Paste into a new code file in Framer. It fills its frame and opens as it comes
// into view. With Pin on it holds itself in the window while the page scrolls on
// (set its height to Fit); inside a sticky frame of a tall section it follows that
// section's scroll instead. Without a photo the tiers show a soft tone.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }

interface FunnelProps {
    photo?: Img
    stages: string
    afterNote: string
    steepness: number
    relief: number
    motion: "Follows scroll" | "Open" | "Closed"
    pin: boolean
    pinLength: number
    fontFamily: string
    scale: number
    imageWidth: string
    textColour: string
    mutedColour: string
    lineColour: string
    tintColour: string
    accentColour: string
    style?: CSSProperties
}

const STAGES = "Saw the ad, 980000, 980000\nVisited the site, 64000, 79500\nStarted a trial, 15800, 24600\nBecame customers, 6200, 11300"

const SHEET = `
.lyr-fnl-track{position:relative;width:100%;height:100%}
.lyr-fnl-pin{position:relative;width:100%;height:100%}
.lyr-fnl-pinned{height:calc(100vh + var(--lyr-fnl-len) * 1vh);height:calc(100svh + var(--lyr-fnl-len) * 1lvh)}
@media (max-width:809px){.lyr-fnl-pinned{height:calc(100vh + var(--lyr-fnl-len) * .78vh);height:calc(100svh + var(--lyr-fnl-len) * .78lvh)}}
.lyr-fnl-pinned>.lyr-fnl-pin{position:sticky;top:0;height:100vh;height:100svh;box-sizing:border-box;padding:clamp(16px,4vw,48px)}
.lyr-fnl{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:0;--lyr-fnl-p:0;-webkit-font-smoothing:antialiased;container-type:inline-size}
.lyr-fnl-row{position:relative;flex:1 1 0;min-height:0;display:flex;border-top:1px solid var(--lyr-fnl-line)}
.lyr-fnl-row:last-child{border-bottom:1px solid var(--lyr-fnl-line)}
.lyr-fnl-lab{flex:none;width:24%;min-width:150px;padding:calc(16px * var(--lyr-fnl-s)) 12px 0 0}
.lyr-fnl-num{position:relative;font-size:calc(34px * var(--lyr-fnl-s));line-height:1;font-weight:500;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
.lyr-fnl-num span{display:block;transition:opacity .45s ease}
.lyr-fnl-num .lyr-fnl-a{position:absolute;left:0;top:0;opacity:0}
.lyr-fnl-open .lyr-fnl-num .lyr-fnl-b,.lyr-fnl[data-done] .lyr-fnl-num .lyr-fnl-b{opacity:0}
.lyr-fnl-open .lyr-fnl-num .lyr-fnl-a,.lyr-fnl[data-done] .lyr-fnl-num .lyr-fnl-a{opacity:1}
.lyr-fnl-stage{margin-top:calc(8px * var(--lyr-fnl-s));font-size:14px;line-height:1.3;letter-spacing:-.005em;color:var(--lyr-fnl-muted)}
.lyr-fnl-delta{margin-top:4px;font-size:14px;line-height:1.3;font-weight:500;color:var(--lyr-fnl-accent);opacity:0;transition:opacity .45s ease .1s}
.lyr-fnl-open .lyr-fnl-delta,.lyr-fnl[data-done] .lyr-fnl-delta{opacity:1}
.lyr-fnl-area{position:relative;flex:1 1 0;min-width:0}
.lyr-fnl-tier{position:absolute;top:0;bottom:-1px;left:50%;width:calc((var(--b) + (var(--a) - var(--b)) * var(--lyr-fnl-p)) * 100%);transform:translateX(-50%);overflow:hidden;background:var(--lyr-fnl-tint)}
.lyr-fnl-tier img,.lyr-fnl-empty{position:absolute;left:50%;top:calc(var(--i) * -100%);width:calc(100% / (var(--b) + (var(--a) - var(--b)) * var(--lyr-fnl-p)));height:calc(var(--n) * 100%);max-width:none;transform:translateX(-50%);object-fit:cover;display:block}
.lyr-fnl-empty{background:radial-gradient(60% 45% at 50% 30%,rgba(255,255,255,.14),rgba(255,255,255,0) 70%),linear-gradient(180deg,rgba(255,255,255,.07),rgba(255,255,255,0))}
@container (max-width:560px){.lyr-fnl-lab{width:38%;min-width:0}.lyr-fnl-stage,.lyr-fnl-delta{font-size:12.5px}}
@media (prefers-reduced-motion:reduce){.lyr-fnl{--lyr-fnl-p:1!important}.lyr-fnl-pinned{height:auto}.lyr-fnl-pinned>.lyr-fnl-pin{position:relative}}
`
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
const ease = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)
const num = (s: string) => Number(String(s).replace(/[^0-9.\-]/g, "")) || 0
const show = (n: number) => Math.round(n).toLocaleString("en-GB")

// The section to follow: the parent of the nearest sticky layer around the funnel (its own pin, or one you
// built), when that section is taller than the window.
function trackOf(el: HTMLElement): HTMLElement | null {
    for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
        if (getComputedStyle(n).position === "sticky") return n.parentElement
    }
    return null
}

/**
 * @framerIntrinsicWidth 1120
 * @framerIntrinsicHeight 600
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function Funnel(props: FunnelProps) {
    const {
        photo,
        stages = STAGES,
        afterNote = "after six months",
        steepness = 84,
        relief = 58,
        motion = "Follows scroll",
        pin = false,
        pinLength = 90,
        fontFamily = "Inter",
        scale = 1,
        imageWidth = "75vw",
        textColour = "#F2F0EA",
        mutedColour = "#8B8880",
        lineColour = "#262623",
        tintColour = "#1B1B19",
        accentColour = "#7D92FF",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const rows = String(stages || "")
        .split("\n")
        .map((l) => l.split(",").map((x) => x.trim()))
        .filter((r) => r[0])
        .map((r) => ({ label: r[0], before: num(r[1] || "0"), after: num(r[2] || r[1] || "0") }))
    const n = Math.max(1, rows.length)
    const steep = clamp((Number(steepness) || 84) / 100, 0.2, 0.95)
    const eased = clamp((Number(relief) || 58) / 100, 0.1, steep)
    const wb = (i: number) => (n === 1 ? 1 : 1 - (i / (n - 1)) * steep)
    const wa = (i: number) => (n === 1 ? 1 : 1 - (i / (n - 1)) * eased)
    const mode = String(motion || "").toLowerCase()
    const fixed = isStatic || mode.includes("open") ? 1 : mode.includes("closed") ? 0 : null
    const pinned = !!pin && !isStatic

    useEffect(() => {
        const el = root.current
        if (!el) return
        const paint = (p: number) => {
            el.style.setProperty("--lyr-fnl-p", String(Math.round(p * 10000) / 10000))
            if (p > 0.985) el.setAttribute("data-done", "")
            else if (p < 0.9) el.removeAttribute("data-done")
        }
        if (fixed !== null) {
            paint(fixed)
            return
        }
        if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
            paint(1)
            return
        }
        const track = trackOf(el)
        let goal = 0,
            now = 0,
            raf = 0,
            last = 0
        const read = () => {
            const vh = window.innerHeight
            if (track) {
                const r = track.getBoundingClientRect(),
                    travel = r.height - vh
                if (travel > 8) {
                    goal = clamp((-r.top / travel - 0.04) / 0.78)
                    return
                }
            }
            const r = el.getBoundingClientRect(),
                from = vh * 0.9
            let to = vh * 0.5 - r.height * 0.5
            const reach = r.top + window.scrollY - (document.documentElement.scrollHeight - vh)
            if (reach > to) to = reach + 8
            goal = from - to > 24 ? clamp((from - r.top) / (from - to)) : r.top < vh ? 1 : 0
        }
        const tick = (t: number) => {
            const dt = last ? Math.min(64, t - last) : 16
            last = t
            now += (goal - now) * (1 - Math.exp(-dt / 120))
            if (Math.abs(goal - now) < 0.0006) now = goal
            paint(ease(now))
            raf = now === goal ? 0 : requestAnimationFrame(tick)
            if (!raf) last = 0
        }
        const wake = () => {
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        wake()
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [fixed, n, pinned])

    const s = Number(scale) || 1
    const vars = {
        "--lyr-fnl-muted": mutedColour,
        "--lyr-fnl-line": lineColour,
        "--lyr-fnl-tint": tintColour,
        "--lyr-fnl-accent": accentColour,
        "--lyr-fnl-s": s,
        "--lyr-fnl-len": Math.max(0, Number(pinLength) || 0),
    } as CSSProperties
    const img = photo && photo.src ? photo : null
    return (
        <div className={"lyr-fnl-track" + (pinned ? " lyr-fnl-pinned" : "")} style={{ ...style, ...vars, color: textColour, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-fnl-pin">
                <div ref={root} className={"lyr-fnl" + (fixed === 1 ? " lyr-fnl-open" : "")} style={{ "--lyr-fnl-p": fixed === 1 ? 1 : 0 } as CSSProperties}>
                    {rows.map((r, i) => {
                        const d = r.before ? Math.round(((r.after - r.before) / r.before) * 100) : 0
                        return (
                            <div className="lyr-fnl-row" key={i}>
                                <div className="lyr-fnl-lab">
                                    <div className="lyr-fnl-num">
                                        <span className="lyr-fnl-b">{show(r.before)}</span>
                                        <span className="lyr-fnl-a">{show(r.after)}</span>
                                    </div>
                                    <div className="lyr-fnl-stage">{r.label}</div>
                                    {d ? <div className="lyr-fnl-delta">{(d > 0 ? "+" : "") + d + "% " + (afterNote || "")}</div> : null}
                                </div>
                                <div className="lyr-fnl-area">
                                    <div className="lyr-fnl-tier" style={{ "--b": wb(i), "--a": wa(i), "--i": i, "--n": n } as CSSProperties}>
                                        {img ? (
                                            <img loading="lazy" decoding="async" sizes={imageWidth || "75vw"} srcSet={img.srcSet} src={img.src} alt={i === 0 ? img.alt || "" : ""} style={{ objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
                                        ) : (
                                            <i className="lyr-fnl-empty" />
                                        )}
                                    </div>
                                </div>
                            </div>
                        )
                    })}
                </div>
            </div>
        </div>
    )
}

addPropertyControls(Funnel, {
    photo: { type: ControlType.ResponsiveImage, title: "Photo", description: "One photo, shown through all the tiers." },
    stages: { type: ControlType.String, title: "Stages", displayTextArea: true, defaultValue: "Saw the ad, 980000, 980000\nVisited the site, 64000, 79500\nStarted a trial, 15800, 24600\nBecame customers, 6200, 11300", description: "One stage per line: name, number before, number after. For example: Visited the site, 64000, 79500" },
    afterNote: { type: ControlType.String, title: "After note", defaultValue: "after six months", description: "Printed after each percentage change." },
    steepness: { type: ControlType.Number, title: "Steepness", min: 20, max: 95, step: 1, unit: "%", defaultValue: 84, description: "How much narrower the last tier is than the first, before." },
    relief: { type: ControlType.Number, title: "Steepness after", min: 10, max: 95, step: 1, unit: "%", defaultValue: 58, description: "The same, after. A smaller number means the lower tiers widen more." },
    motion: { type: ControlType.Enum, title: "Motion", options: ["Follows scroll", "Open", "Closed"], optionTitles: ["Follows scroll", "Open", "Closed"], defaultValue: "Follows scroll" },
    pin: { type: ControlType.Boolean, title: "Pin", defaultValue: false, description: "Holds the funnel in the window while the page scrolls on, then lets it go. Set the height to Fit: it becomes one window plus Pin length." },
    pinLength: { type: ControlType.Number, title: "Pin length", min: 30, max: 300, step: 10, unit: "vh", defaultValue: 90, hidden: (p: Partial<FunnelProps>) => !p.pin, description: "How much scrolling the pinned funnel takes. Phones use a little less." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    scale: { type: ControlType.Number, title: "Scale", min: 0.6, max: 1.4, step: 0.05, defaultValue: 1 },
    imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "75vw", description: "How wide the photo is drawn at most, for choosing a file size." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
    mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8B8880" },
    lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#262623" },
    tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19" },
    accentColour: { type: ControlType.Color, title: "Accent colour", defaultValue: "#7D92FF" },
})

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.