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