Components / Numbers and data
Moved
A pinned closing scene: one huge number rolls digit by digit from before to after while the note above it changes.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#E6E5E1
Scroll inside the preview.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Moved shows up in the Insert menu, and its controls appear in the right panel.
// Moved · Layr Components
//
// A closing scene. One huge number rolls digit by digit from what it was to
// what it became while the section is pinned, a bar fills under it, and the
// note above it changes with it. Let go part way and it settles to the start
// or the end.
// Paste into a new code file in Framer. Place it in the page flow at full width; it is taller than the window and pins itself while the number rolls.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
interface MovedProps {
from: string
to: string
unit: string
headline: string
quiet: string
fromNote: string
toNote: string
scrollLength: number
fontFamily: string
ink: string
mute: string
quietTone: string
accent: string
hairline: string
topSpace: number
sideSpace: number
style?: CSSProperties
}
const CSS = `
.lyr-mvd{position:relative;width:100%;color:var(--ink);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;height:calc(100vh + var(--step) * 1vh);height:calc(100svh + var(--step) * 1svh)}
.lyr-mvd *{box-sizing:border-box}
.lyr-mvd-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;container-type:size;overflow:clip}
.lyr-mvd.lyr-mvd-static{height:760px}
.lyr-mvd.lyr-mvd-static .lyr-mvd-pin{position:relative;height:760px}
.lyr-mvd-box{position:absolute;inset:0;padding:var(--top) var(--side) clamp(20px,5cqh,48px);display:flex;flex-direction:column;--em:clamp(110px,min(34cqw,(100cqh - var(--top) - 250px) * 1.25,(100cqw - 2 * var(--side)) / var(--len)),720px)}
.lyr-mvd-rule{height:1px;background:var(--hair);margin-top:clamp(8px,3cqh,28px);flex:none}
.lyr-mvd-h{margin:clamp(18px,4.5cqh,48px) 0 0;max-width:15em;font-weight:400;font-size:clamp(30px,min(5cqw,8cqh),72px);line-height:1.03;letter-spacing:-0.04em;text-wrap:balance}
.lyr-mvd-h span{color:var(--quiet)}
.lyr-mvd-gap{flex:1 1 0;min-height:8px}
.lyr-mvd-note{position:relative;height:1.4em;font-size:clamp(15px,1.25cqw,18px);line-height:1.4;letter-spacing:-0.015em;color:var(--mute);overflow:hidden;margin-bottom:6px}
.lyr-mvd-note span{position:absolute;left:0;top:0;white-space:nowrap;transition:opacity .3s ease}
.lyr-mvd-note .lyr-mvd-b{opacity:0}
.lyr-mvd.lyr-mvd-done .lyr-mvd-note .lyr-mvd-a{opacity:0}
.lyr-mvd.lyr-mvd-done .lyr-mvd-note .lyr-mvd-b{opacity:1;color:var(--ink)}
.lyr-mvd-row{display:flex;align-items:flex-end;margin-left:-0.04em;font-size:var(--em)}
.lyr-mvd-num{display:flex;line-height:.8;letter-spacing:-0.055em;font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1;white-space:nowrap}
.lyr-mvd-col{display:block;height:.8em;overflow:hidden}
.lyr-mvd-col span{display:block;will-change:transform}
.lyr-mvd-col i{display:block;height:.8em;font-style:normal}
.lyr-mvd-unit{font-size:.24em;line-height:1;letter-spacing:-0.045em;margin:0 0 .12em .2em;white-space:nowrap}
.lyr-mvd-bar{position:relative;height:2px;margin-top:clamp(14px,3cqh,28px);background:var(--hair);flex:none}
.lyr-mvd-bar span{position:absolute;inset:0;background:var(--accent);transform-origin:left center;will-change:transform}
@media (prefers-reduced-motion: reduce){.lyr-mvd-note span{transition:none}}
`
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const ease = (k: number) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2)
const hidden: CSSProperties = { position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 760
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any
*/
export default function Moved(props: MovedProps) {
const {
from: fromProp = "840",
to: toProp = "96",
unit = "ms",
headline = "Have a number that needs moving?",
quiet = "Say where it sits today.",
fromNote = "Where it started: page load, 95th percentile",
toNote = "Where it sits now, ten weeks later",
scrollLength = 90,
fontFamily = "Inter",
ink = "#F0ECE4",
mute = "#918D84",
quietTone = "#625F58",
accent = "#FFB02E",
hairline = "rgba(240,236,228,0.14)",
topSpace = 76,
sideSpace = 48,
} = props
const isStatic = useIsStaticRenderer()
const root = useRef<HTMLDivElement>(null)
const cols = useRef<(HTMLSpanElement | null)[]>([])
const bar = useRef<HTMLSpanElement>(null)
const from = String(fromProp || "")
const to = String(toProp || "")
const digits = Math.max(from.length, to.length, 1)
const a = from.padEnd(digits, " ")
const b = to.padEnd(digits, " ")
const len = digits * 0.56 + 0.1 + Math.max(String(unit || "").length * 0.145, 0.1)
useEffect(() => {
if (isStatic) return
const el = root.current
if (!el) return
const pin = el.querySelector(".lyr-mvd-pin") as HTMLElement
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
let shown = 0
let goal = 0
let raw = 0
let raf = 0
let last = 0
let idle: ReturnType<typeof setTimeout> | undefined
let painted = NaN
const paint = (p: number) => {
if (p === painted) return
painted = p
cols.current.forEach((c, n) => {
if (!c) return
const k = ease(clamp(p * (1 + 0.16 * (digits - 1)) - 0.16 * n, 0, 1))
c.style.transform = `translate3d(0,${(-k * 50).toFixed(3)}%,0)`
// a column that ends (or starts) empty closes up, so the unit stays beside the number
const box = c.parentElement as HTMLElement
if (b[n] === " " && a[n] !== " ") box.style.maxWidth = `${((1 - k) * 0.7).toFixed(3)}em`
else if (a[n] === " " && b[n] !== " ") box.style.maxWidth = `${(k * 0.7).toFixed(3)}em`
else box.style.maxWidth = ""
})
if (bar.current) bar.current.style.transform = `scaleX(${p.toFixed(4)})`
el.classList.toggle("lyr-mvd-done", p > 0.86)
}
const where = () => {
const r = el.getBoundingClientRect()
const range = r.height - pin.offsetHeight
if (r.width === 0 || range <= 0) return 0
return clamp(-r.top / range, 0, 1)
}
const tick = (now: number) => {
const dt = Math.min(64, now - last || 16)
last = now
shown += (goal - shown) * (1 - Math.exp(-dt / 120))
if (Math.abs(goal - shown) < 0.001) shown = goal
paint(shown)
raf = shown === goal ? 0 : requestAnimationFrame(tick)
}
const kick = () => {
if (!raf) {
last = performance.now()
raf = requestAnimationFrame(tick)
}
}
const onScroll = () => {
raw = where()
if (reduced) {
shown = goal = Math.round(raw)
paint(shown)
return
}
goal = raw
kick()
clearTimeout(idle)
// let go part way: settle to the start or the end
idle = setTimeout(() => {
goal = raw > 0.5 ? 1 : 0
kick()
}, 220)
}
raw = where()
shown = goal = raw > 0.5 ? 1 : 0
paint(shown)
window.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("resize", onScroll)
return () => {
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
cancelAnimationFrame(raf)
clearTimeout(idle)
}
}, [isStatic, digits, a, b])
const vars = {
"--step": clamp(scrollLength || 90, 30, 300),
"--len": len.toFixed(3),
"--top": `${topSpace}px`,
"--side": `${sideSpace}px`,
"--ink": ink,
"--mute": mute,
"--quiet": quietTone,
"--accent": accent,
"--hair": hairline,
fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
} as CSSProperties
const said = `${from}${unit ? " " + unit : ""} ${fromNote}; ${to}${unit ? " " + unit : ""} ${toNote}.`
return (
<div ref={root} className={`lyr-mvd${isStatic ? " lyr-mvd-static" : ""}`} style={vars}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div className="lyr-mvd-pin">
<div className="lyr-mvd-box">
<div className="lyr-mvd-rule" />
<h2 className="lyr-mvd-h">
{headline} <span>{quiet}</span>
</h2>
<div className="lyr-mvd-gap" />
<p style={hidden}>{said}</p>
<div className="lyr-mvd-note" aria-hidden="true">
<span className="lyr-mvd-a">{fromNote}</span>
<span className="lyr-mvd-b">{toNote}</span>
</div>
<div className="lyr-mvd-row" aria-hidden="true">
<div className="lyr-mvd-num">
{Array.from({ length: digits }).map((_, n) => (
<span className="lyr-mvd-col" key={n}>
<span
ref={(node) => {
cols.current[n] = node
}}
>
<i>{a[n] === " " ? "\u00a0" : a[n]}</i>
<i>{b[n] === " " ? "\u00a0" : b[n]}</i>
</span>
</span>
))}
</div>
{unit ? <div className="lyr-mvd-unit">{unit}</div> : null}
</div>
<div className="lyr-mvd-bar">
<span ref={bar} style={{ transform: "scaleX(0)" }} />
</div>
</div>
</div>
</div>
)
}
addPropertyControls(Moved, {
from: { type: ControlType.String, title: "Number before", defaultValue: "840" },
to: { type: ControlType.String, title: "Number after", defaultValue: "96" },
unit: { type: ControlType.String, title: "Unit", defaultValue: "ms" },
headline: { type: ControlType.String, title: "Headline", defaultValue: "Have a number that needs moving?", displayTextArea: true },
quiet: { type: ControlType.String, title: "Headline, quiet part", defaultValue: "Say where it sits today.", displayTextArea: true },
fromNote: { type: ControlType.String, title: "Note before", defaultValue: "Where it started: page load, 95th percentile" },
toNote: { type: ControlType.String, title: "Note after", defaultValue: "Where it sits now, ten weeks later" },
scrollLength: { type: ControlType.Number, title: "Scroll length", defaultValue: 90, min: 30, max: 300, step: 5, unit: "%", description: "How much scrolling the roll takes, as a share of the window height." },
fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F0ECE4" },
mute: { type: ControlType.Color, title: "Muted", defaultValue: "#918D84" },
quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#625F58" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FFB02E" },
hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(240,236,228,0.14)" },
topSpace: { type: ControlType.Number, title: "Top space", defaultValue: 76, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above." },
sideSpace: { type: ControlType.Number, title: "Side space", defaultValue: 48, min: 0, max: 120, step: 1, unit: "px" },
})
In this preview
No pictures needed.