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