NumberTicker
Eased counting on scroll into view, with tabular figures so climbing digits never jitter the layout.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/number-ticker.jsonJSin-view
Components
Bundle
Runtime deps
"use client";
import * as React from "react";
import { cx, easeOutCubic, useMotionRateRef, useMotionTrigger } from "@pouriahlabs/wisp-ui";
/**
* NumberTicker — eased counting, triggered when the number scrolls into view.
*
* Set `tabular-nums` on anything that counts. Proportional digits have
* different widths, so a climbing number visibly reflows its own container and
* nudges whatever sits beside it — a small, constant jitter that reads as
* cheapness. The default class here sets it for you.
*
* Under reduced motion the final value renders immediately. The number is the
* information; the climb is the decoration.
*
* The intermediate digits mid-count are never the accessible name — a screen
* reader that queried this element mid-animation would read whatever random
* partial value the current frame happened to land on. The visible, animating
* text is `aria-hidden`; the element's real `aria-label` is always the
* settled final value, so it reads correctly however it's encountered.
*/
export interface NumberTickerProps extends React.HTMLAttributes<HTMLSpanElement> {
/** Final value. */
value: number;
/** Value to count from. */
from?: number;
/** Decimal places. */
decimals?: number;
/** Duration, ms. */
duration?: number;
/** Rendered before the number. */
prefix?: string;
/** Rendered after the number. */
suffix?: string;
/** Locale for grouping separators. Pass `false` for none. */
locale?: string | false;
/** Replay every time it re-enters the viewport, not just the first time. */
replay?: boolean;
}
export function NumberTicker({
value,
from = 0,
decimals = 0,
duration = 1500,
prefix = "",
suffix = "",
locale = "en-US",
replay = false,
className,
...props
}: NumberTickerProps) {
const [display, setDisplay] = React.useState(from);
const raf = React.useRef(0);
// Speed scales the elapsed time; duration stretches the target it is measured
// against. Both read live, so nudging a slider retimes the count in flight.
const rateRef = useMotionRateRef();
const ref = useMotionTrigger<HTMLSpanElement>(
{
replay,
rootMargin: "0px 0px -10% 0px",
run: (done) => {
let last = 0;
let elapsed = 0;
const step = (now: number) => {
elapsed += (last ? now - last : 0) * rateRef.current.speed;
last = now;
const t = Math.min(1, elapsed / (duration * (rateRef.current.duration || 1)));
setDisplay(from + (value - from) * easeOutCubic(t));
if (t < 1) raf.current = requestAnimationFrame(step);
else done();
};
raf.current = requestAnimationFrame(step);
},
settle: () => {
cancelAnimationFrame(raf.current);
setDisplay(value);
},
cancel: () => cancelAnimationFrame(raf.current),
},
[value, from, duration, replay],
);
const format = (n: number) =>
locale === false
? n.toFixed(decimals)
: n.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return (
<span
ref={ref}
className={cx("tabular-nums", className)}
aria-label={`${prefix}${format(value)}${suffix}`}
{...props}
>
<span aria-hidden>
{prefix}
{format(display)}
{suffix}
</span>
</span>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React