Layr Components

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
90%
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#E6E5E1
76 px
48 px

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.