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
#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.