Layr Components

Components / Numbers and data

Roll

A number whose digits roll into place like an odometer the first time it scrolls into view, with an optional unit after it.

Scroll insideFull screen
Props
64 px
22 px
400
-0.05 em
#0E0E0D
#6E6E6B

Scroll down to roll the number. Replay starts it again.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Roll shows up in the Insert menu, and its controls appear in the right panel.
// Roll · Layr Components
//
// A number whose digits roll into place like an odometer the first time it
// scrolls into view, with an optional unit after it. Any character that is not
// a digit stays still. Already on screen when the page loads, it simply shows.
// Paste into a new code file in Framer. It sizes itself to the number (auto width and height).

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

interface RollProps {
    figure: string
    unit: string
    fontFamily: string
    size: number
    unitSize: number
    weight: number
    tracking: number
    ink: string
    unitColour: string
    align: string
    style?: CSSProperties
}

const CSS = `
.lyr-rol{display:flex;align-items:baseline;white-space:nowrap;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1}
.lyr-rol-n{display:flex;line-height:1}
.lyr-rol-c{display:block;height:1em;overflow:hidden}
.lyr-rol-s{display:block;transform:translate3d(0,calc(var(--k) * -1em),0);transition:transform 1.1s cubic-bezier(.16,.84,.24,1);transition-delay:calc(var(--c) * 70ms)}
.lyr-rol-s i{display:block;height:1em;font-style:normal}
.lyr-rol.lyr-rol-wait .lyr-rol-s{transform:translate3d(0,0,0);transition:none}
.lyr-rol-u{line-height:1;margin-left:.22em}
@media (prefers-reduced-motion: reduce){.lyr-rol-s{transition:none}.lyr-rol.lyr-rol-wait .lyr-rol-s{transform:translate3d(0,calc(var(--k) * -1em),0)}}
`

/**
 * @framerSupportedLayoutWidth auto
 * @framerSupportedLayoutHeight auto
 */
export default function Roll(props: RollProps) {
    const { figure = "96", unit = "ms", fontFamily = "Inter", size = 64, unitSize = 22, weight = 400, tracking = -0.05, ink = "#F0ECE4", unitColour = "#918D84", align = "Left" } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const glyphs = Array.from(String(figure || ""))

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        if (!el || typeof IntersectionObserver !== "function") return
        const r = el.getBoundingClientRect()
        // already on screen when the page loads: leave it alone
        if (r.top < window.innerHeight && r.bottom > 0) return
        el.classList.add("lyr-rol-wait")
        const io = new IntersectionObserver(
            (entries) => {
                if (entries.some((e) => e.isIntersecting)) {
                    el.getBoundingClientRect()
                    el.classList.remove("lyr-rol-wait")
                    io.disconnect()
                }
            },
            { rootMargin: "0px 0px -12% 0px" }
        )
        io.observe(el)
        return () => io.disconnect()
    }, [isStatic, figure])

    let col = 0
    const justify = String(align).toLowerCase().includes("right") ? "flex-end" : String(align).toLowerCase().includes("cent") ? "center" : "flex-start"
    return (
        <div ref={root} className="lyr-rol" style={{ fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`, fontWeight: weight, color: ink, justifyContent: justify, ...props.style }}>
            <style dangerouslySetInnerHTML={{ __html: CSS }} />
            <span className="lyr-rol-n" style={{ fontSize: size, letterSpacing: `${tracking}em` }} role="text" aria-label={`${figure}${unit ? " " + unit : ""}`}>
                {glyphs.map((g, n) => {
                    if (!/[0-9]/.test(g))
                        return (
                            <span className="lyr-rol-c" key={n} aria-hidden="true">
                                <span>
                                    <i>{g}</i>
                                </span>
                            </span>
                        )
                    const d = Number(g)
                    const turns = 3 + (col % 3)
                    const c = col++
                    const strip = Array.from({ length: turns + 1 }, (_, s) => (d - turns + s + 100) % 10)
                    return (
                        <span className="lyr-rol-c" key={n} aria-hidden="true">
                            <span className="lyr-rol-s" style={{ "--k": turns, "--c": c } as CSSProperties}>
                                {strip.map((x, s) => (
                                    <i key={s}>{x}</i>
                                ))}
                            </span>
                        </span>
                    )
                })}
            </span>
            {unit ? (
                <span className="lyr-rol-u" style={{ fontSize: unitSize, color: unitColour, letterSpacing: "-0.02em" }} aria-hidden="true">
                    {unit}
                </span>
            ) : null}
        </div>
    )
}

addPropertyControls(Roll, {
    figure: { type: ControlType.String, title: "Number", defaultValue: "96", description: "Digits roll; any other character stays still." },
    unit: { type: ControlType.String, title: "Unit", defaultValue: "ms" },
    size: { type: ControlType.Number, title: "Size", defaultValue: 64, min: 12, max: 400, step: 1, unit: "px" },
    unitSize: { type: ControlType.Number, title: "Unit size", defaultValue: 22, min: 8, max: 200, step: 1, unit: "px" },
    weight: { type: ControlType.Number, title: "Weight", defaultValue: 400, min: 100, max: 900, step: 100 },
    tracking: { type: ControlType.Number, title: "Tracking", defaultValue: -0.05, min: -0.1, max: 0.1, step: 0.005, unit: "em" },
    align: { type: ControlType.Enum, title: "Align", options: ["Left", "Center", "Right"], optionTitles: ["Left", "Center", "Right"], defaultValue: "Left" },
    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" },
    unitColour: { type: ControlType.Color, title: "Unit colour", defaultValue: "#918D84" },
})

In this preview

No pictures needed.