Layr Components

Components / Numbers and data

Count Up

A large number that counts to its value the first time it scrolls into view, holding its final width so nothing beside it moves.

Props
58
0
0
1.4
0 s
64 px
400
-0.06 em
0.58 em
#0E0E0D
0.28 em

Press Replay to count again.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Count Up shows up in the Insert menu, and its controls appear in the right panel.
// Count Up · Layr Components
//
// A large number that counts to its value the first time it scrolls into view.
// The figure keeps the width of its final value from the start, so nothing
// beside it moves while it counts. Counts down as well as up.
// Paste into a new code file in Framer. It sizes itself to the number (auto
// width and height); the server and the canvas show the final value.

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

interface CountUpProps {
    to: number
    from: number
    decimals: number
    prefix: string
    suffix: string
    duration: number
    delay: number
    fontFamily: string
    fontSize: number
    fontWeight: number
    tracking: number
    cell: number
    color: string
    suffixScale: number
    suffixColor: string
    style?: CSSProperties
}

// The share of the figure that has to be on screen before it counts.
const SEEN = 0.4

const num = (v: unknown) => {
    const n = Number(v)
    return isFinite(n) ? n : 0
}
// "58", "12.5". A value that rounds to nothing is spelled without a minus sign.
const spell = (v: number, places: number) => {
    const s = v.toFixed(places)
    return Number(s) === 0 ? s.replace("-", "") : s
}

// One cell per character of the final value, so the width is settled before anything moves.
// A leading cell that is only needed on the way down (120 to 94) takes no room once it is empty.
const SHEET = `
.lyr-cu-d{display:inline-block;width:var(--lyr-cu-cell);text-align:center}
.lyr-cu-x:empty{display:none}
.lyr-cu-s{font-size:var(--lyr-cu-ss);letter-spacing:-0.02em;margin-left:0.3em;color:var(--lyr-cu-sc)}
`

/**
 * @framerSupportedLayoutWidth auto
 * @framerSupportedLayoutHeight auto
 */
export default function CountUp(props: CountUpProps) {
    const {
        to: toValue = 58,
        from: fromValue = 0,
        decimals = 0,
        prefix = "",
        suffix = "",
        duration = 1.4,
        delay = 0,
        fontFamily = "Inter",
        fontSize = 64,
        fontWeight = 400,
        tracking = -0.06,
        cell = 0.58,
        color = "#0D0D0E",
        suffixScale = 0.28,
        suffixColor = "",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const still = isStatic || RenderTarget.current() === RenderTarget.canvas

    const from = num(fromValue)
    const to = num(toValue)
    const places = Math.min(6, Math.max(0, Math.round(num(decimals))))
    const seconds = Math.max(0.05, num(duration))
    const wait = Math.max(0, num(delay))
    const last = spell(to, places) // what the figure reads at rest, and all that React ever renders
    const first = spell(from, places)
    const extra = Math.max(0, first.length - last.length) // cells needed only on the way down past a power of ten
    const count = extra + last.length

    const ref = useRef<HTMLSpanElement>(null)
    const cells = useRef<(HTMLSpanElement | null)[]>([])
    const done = useRef(false)

    useEffect(() => {
        // Writes a value into the cells from the right. React never hears about it.
        const paint = (text: string) => {
            const shift = count - text.length
            for (let i = 0; i < count; i++) {
                const el = cells.current[i]
                if (!el) continue
                const ch = i >= shift ? text.charAt(i - shift) : ""
                // A sign or a decimal point keeps its room: it is hidden, never taken out.
                // A digit cell has nothing of its own, so it is never hidden, and one that was a sign
                // cell for an earlier value is shown again here.
                const show = ch || el.getAttribute("data-own") || ""
                if (el.textContent !== show) el.textContent = show
                const visibility = show && !ch ? "hidden" : ""
                if (el.style.visibility !== visibility) el.style.visibility = visibility
            }
        }
        const el = ref.current
        if (still || !el) return
        // Nothing to do on the first run. After a change of value it clears what an earlier count left behind.
        paint(last)
        if (done.current || first === last) return
        if (typeof IntersectionObserver === "undefined") return
        if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return

        let frame = 0
        let timer = 0
        let shown = last
        const run = () => {
            const t0 = performance.now()
            const tick = (t: number) => {
                const p = Math.min(1, Math.max(0, (t - t0) / (seconds * 1000)))
                const text = p < 1 ? spell(from + (to - from) * (1 - Math.pow(1 - p, 4)), places) : last
                if (text !== shown) {
                    shown = text
                    paint(text)
                }
                // The value only ever moves towards the end, so the first frame that reads the final value is the last one.
                frame = text === last ? 0 : requestAnimationFrame(tick)
            }
            frame = requestAnimationFrame(tick)
        }
        const io = new IntersectionObserver(
            (entries: IntersectionObserverEntry[]) => {
                const entry = entries[entries.length - 1]
                if (!entry || !entry.isIntersecting) return
                // The start value goes in as the first edge of the figure arrives, before anyone can read it,
                // so the change from the final value (which the server sent) is never seen.
                if (shown !== first) {
                    shown = first
                    paint(first)
                }
                if (entry.intersectionRatio < SEEN) return
                done.current = true
                io.disconnect()
                if (wait > 0) timer = window.setTimeout(run, wait * 1000)
                else run()
            },
            { threshold: [0, SEEN] }
        )
        io.observe(el)
        return () => {
            io.disconnect()
            window.clearTimeout(timer)
            cancelAnimationFrame(frame)
        }
    }, [from, to, places, seconds, wait, first, last, count, still])

    const vars = { "--lyr-cu-cell": `${cell}em`, "--lyr-cu-ss": `${suffixScale}em`, "--lyr-cu-sc": suffixColor || color } as CSSProperties
    const slots: ReactNode[] = []
    for (let i = 0; i < count; i++) {
        const ch = i < extra ? "" : last.charAt(i - extra)
        const digit = ch === "" || (ch >= "0" && ch <= "9")
        slots.push(
            <span
                key={count - i}
                ref={(node: HTMLSpanElement | null) => {
                    cells.current[i] = node
                }}
                aria-hidden="true"
                className={i < extra ? "lyr-cu-d lyr-cu-x" : digit ? "lyr-cu-d" : undefined}
                data-own={digit ? undefined : ch}
            >
                {ch}
            </span>
        )
    }

    return (
        <span
            ref={ref}
            style={{
                ...style,
                ...vars,
                display: "inline-flex",
                alignItems: "baseline",
                whiteSpace: "nowrap",
                fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
                fontSize,
                fontWeight,
                letterSpacing: `${tracking}em`,
                lineHeight: 0.9,
                color,
            }}
        >
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            {prefix ? <span>{prefix}</span> : null}
            <span aria-label={last}>{slots}</span>
            {suffix ? <span className="lyr-cu-s">{suffix}</span> : null}
        </span>
    )
}

addPropertyControls(CountUp, {
    to: { type: ControlType.Number, title: "Value", defaultValue: 58, step: 1 },
    from: { type: ControlType.Number, title: "Starts at", defaultValue: 0, step: 1 },
    decimals: { type: ControlType.Number, title: "Decimals", defaultValue: 0, min: 0, max: 2, step: 1, displayStepper: true },
    prefix: { type: ControlType.String, title: "Before", defaultValue: "" },
    suffix: { type: ControlType.String, title: "After", defaultValue: "" },
    duration: { type: ControlType.Number, title: "Seconds", defaultValue: 1.4, min: 0.2, max: 5, step: 0.1 },
    delay: { type: ControlType.Number, title: "Delay", defaultValue: 0, min: 0, max: 2, step: 0.05, unit: "s" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
    fontSize: { type: ControlType.Number, title: "Size", defaultValue: 64, min: 10, max: 320, step: 1, unit: "px" },
    fontWeight: { type: ControlType.Number, title: "Weight", defaultValue: 400, min: 100, max: 900, step: 100 },
    tracking: { type: ControlType.Number, title: "Tracking", defaultValue: -0.06, min: -0.12, max: 0.1, step: 0.005, unit: "em" },
    cell: { type: ControlType.Number, title: "Digit cell", defaultValue: 0.58, min: 0.4, max: 0.8, step: 0.01, unit: "em" },
    color: { type: ControlType.Color, title: "Colour", defaultValue: "#0D0D0E" },
    suffixScale: { type: ControlType.Number, title: "After size", defaultValue: 0.28, min: 0.1, max: 1, step: 0.02, unit: "em" },
    suffixColor: { type: ControlType.Color, title: "After colour", optional: true },
})

In this preview

No pictures needed.