effects
Number Ticker
A per-digit slot-machine ticker. Each digit occupies its own vertical drum strip and springs through sequential values to its target, with the most-significant digit landing first for a left-to-right cascade. Fixed per-position digit width (tabular-nums) prevents layout jitter, and a frosted-glass vignette mask hides the rolling neighbours. Triggers in-view via IntersectionObserver, re-animates on value change, and snaps instantly under reduced motion.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/number-tickerUsage
Usagetsx
<NumberTicker value={0} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | — | Target number to count to. Changing it re-animates from the current value. |
| startValue | number | 0 | Value to count from on first in-view trigger. |
| duration | number | 1200 | Total animation budget in ms. Stagger + spring delay derive from this. |
| delay | number | 0 | Delay in ms before animating once in view. |
| decimalPlaces | number | 0 | Fractional digits to display and animate. |
| prefix | string | — | Static string prepended (e.g. "$", "+"). Not animated. |
| suffix | string | — | Static string appended (e.g. "%", "K+"). Not animated. |
| direction | enum | up | Digit scroll direction. "up" rolls toward larger values. |
| flash | boolean | true | Accent landing-flash on settle. |
| grouping | boolean | true | Render grouping separators (commas) between thousands. |
| delimiter | boolean | false | Use a hairline divider (border-border) instead of a comma glyph between groups. |
| once | boolean | true | Animate only the first time the element enters the viewport. |
| className | string | Forwarded to the root span (font, size, color overrides). |
Source
number-ticker.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
motion,
useReducedMotion,
useSpring,
useMotionValue,
type MotionValue,
} from "motion/react";
import styles from "./number-ticker.module.css";
/* Per-digit spring — snappy-premium, not bouncy (creative brief). */
const SPRING = { stiffness: 280, damping: 28, mass: 0.6 } as const;
/* Stagger: most-significant digit leads, least lags. Fraction of the total
animation budget per position. These are SCENE timings (seconds-domain),
distinct from the --duration CSS token used for UI transitions (~300ms). */
const STAGGER_COEFF = 0.04;
export type NumberTickerProps = {
/** Target number to count to. Changing it re-animates from the current value. */
value: number;
/** Value to count from on first in-view trigger. */
startValue?: number;
/** Total animation budget in ms. Stagger + spring delay derive from this. */
duration?: number;
/** Delay in ms before animating once in view. */
delay?: number;
/** Fractional digits to display and animate. */
decimalPlaces?: number;
/** Static string prepended (e.g. "$", "+"). Not animated. */
prefix?: string;
/** Static string appended (e.g. "%", "K+"). Not animated. */
suffix?: string;
/** Digit scroll direction. "up" rolls toward larger values. */
direction?: "up" | "down";
/** Accent landing-flash on settle. */
flash?: boolean;
/** Render grouping separators (commas) between thousands. */
grouping?: boolean;
/** Use a hairline divider (border-border) instead of a comma glyph between groups. */
delimiter?: boolean;
/** Animate only the first time the element enters the viewport. */
once?: boolean;
/** Forwarded to the root span (font, size, color overrides). */
className?: string;
};
/* Format a number into the fixed-width digit string we animate. Returns the
integer + fractional digit characters and the human-readable label. */
function format(
value: number,
decimalPlaces: number,
grouping: boolean,
): { label: string; intGroups: string[]; fracDigits: string[] } {
const fixed = Math.abs(value).toFixed(decimalPlaces);
const [intPart, fracPart = ""] = fixed.split(".");
const groups: string[] = [];
if (grouping) {
let rest = intPart;
while (rest.length > 3) {
groups.unshift(rest.slice(-3));
rest = rest.slice(0, -3);
}
groups.unshift(rest);
} else {
groups.push(intPart);
}
const sign = value < 0 ? "-" : "";
const label =
sign + groups.join(",") + (fracPart ? "." + fracPart : "");
return {
label,
intGroups: groups,
fracDigits: fracPart.split(""),
};
}
/* A single drum strip: a stacked 0–9 column that springs its y-offset so the
target digit sits centred in the masked window. */
function DigitDrum({
target,
delaySec,
direction,
digitHeight,
flash,
settleKey,
}: {
target: number;
delaySec: number;
direction: "up" | "down";
digitHeight: number;
flash: boolean;
settleKey: number;
}) {
const cells = direction === "up" ? DIGITS : DIGITS_DESC;
// Row index of the target glyph within the stacked column. For "down" the
// column is reversed, so the same digit sits at a different row.
const targetRow = direction === "up" ? target : 9 - target;
const settled = -targetRow * digitHeight;
const y = useMotionValue(0);
const spring = useSpring(y, SPRING);
const [landed, setLanded] = useState(false);
useEffect(() => {
setLanded(false);
const id = window.setTimeout(() => {
// Slide the column so the target row centres in the masked window.
y.set(settled);
}, delaySec * 1000);
const unsub = spring.on("change", (latest) => {
if (Math.abs(latest - settled) < 0.5) setLanded(true);
});
return () => {
window.clearTimeout(id);
unsub();
};
// settleKey forces a fresh roll when the value prop changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, delaySec, digitHeight, settleKey, settled]);
return (
<span
className={`${styles.strip} ${styles.masked}`}
style={{ ["--nt-digit-h" as string]: `${digitHeight}px` }}
>
<DrumColumn
spring={spring}
target={target}
cells={cells}
landed={landed && flash}
/>
</span>
);
}
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const DIGITS_DESC = [...DIGITS].reverse();
function DrumColumn({
spring,
target,
cells,
landed,
}: {
spring: MotionValue<number>;
target: number;
cells: number[];
landed: boolean;
}) {
return (
<motion.span className={styles.column} style={{ y: spring }}>
{cells.map((n) => (
<span
key={n}
className={[
styles.cell,
n === target ? styles.cellOn : styles.cellOff,
n === target && landed ? styles.flash : "",
]
.filter(Boolean)
.join(" ")}
>
{n}
</span>
))}
</motion.span>
);
}
export function NumberTicker({
value,
startValue = 0,
duration = 1200,
delay = 0,
decimalPlaces = 0,
prefix,
suffix,
direction = "up",
flash = true,
grouping = true,
delimiter = false,
once = true,
className = "",
}: NumberTickerProps) {
const rootRef = useRef<HTMLSpanElement>(null);
// Mount + reduced-motion gate (contract snippet — avoids flash & React #418).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
// In-view trigger; until fired the drum shows startValue.
const [inView, setInView] = useState(false);
// settleKey bumps on each (re)trigger so digits re-roll from current state.
const [settleKey, setSettleKey] = useState(0);
// Measured glyph box for fixed strip height (post-mount, hydration-safe).
const [digitHeight, setDigitHeight] = useState(0);
const measureRef = useRef<HTMLSpanElement>(null);
const target = format(value, decimalPlaces, grouping);
const start = format(startValue, decimalPlaces, grouping);
const animate = mounted && !reduce;
// Measure one glyph after mount.
useEffect(() => {
if (!animate || !measureRef.current) return;
setDigitHeight(measureRef.current.getBoundingClientRect().height);
}, [animate]);
// IntersectionObserver.
useEffect(() => {
if (!animate || !rootRef.current) return;
const el = rootRef.current;
const obs = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setInView(true);
if (once) obs.disconnect();
} else if (!once) {
setInView(false);
}
}
},
{ threshold: 0.4 },
);
obs.observe(el);
return () => obs.disconnect();
}, [animate, once]);
// Re-trigger when value changes while already in view.
useEffect(() => {
if (inView) setSettleKey((k) => k + 1);
}, [value, inView]);
const ariaLabel = `${prefix ?? ""}${target.label}${suffix ?? ""}`;
// Static base — server, first client render, and reduced-motion all use this.
// Identical markup on server & first client paint => no hydration mismatch.
const showLabel = !animate || !inView ? start.label : target.label;
const staticLabel = !animate
? target.label // reduced motion / pre-mount: snap to final value
: showLabel;
// Total digit count drives the stagger so the MSB leads.
const intDigits = target.intGroups.join("");
const totalDigits = intDigits.length + target.fracDigits.length;
return (
<span
ref={rootRef}
role="img"
aria-label={ariaLabel}
aria-live="off"
className={[styles.root, className].filter(Boolean).join(" ")}
>
{/* Hidden glyph used to measure a single digit's box once mounted. */}
{animate && digitHeight === 0 && (
<span
ref={measureRef}
aria-hidden="true"
style={{ position: "absolute", visibility: "hidden", pointerEvents: "none" }}
>
0
</span>
)}
{prefix && (
<span aria-hidden="true" className={styles.affix}>
{prefix}
</span>
)}
{animate && inView && digitHeight > 0 ? (
<DrumValue
target={target}
duration={duration}
delay={delay}
direction={direction}
digitHeight={digitHeight}
flash={flash}
delimiter={delimiter}
settleKey={settleKey}
totalDigits={totalDigits}
/>
) : (
<span aria-hidden="true">{staticLabel}</span>
)}
{suffix && (
<span aria-hidden="true" className={styles.affix}>
{suffix}
</span>
)}
</span>
);
}
/* Lays out grouped integer digit-drums, comma delimiters, decimal point, and
fractional digit-drums. The stagger index counts across ALL digits so the
most-significant leads by ~`duration * 0.04 * (n - i)`. */
function DrumValue({
target,
duration,
delay,
direction,
digitHeight,
flash,
delimiter,
settleKey,
totalDigits,
}: {
target: ReturnType<typeof format>;
duration: number;
delay: number;
direction: "up" | "down";
digitHeight: number;
flash: boolean;
delimiter: boolean;
settleKey: number;
totalDigits: number;
}) {
let digitIndex = -1;
const delayFor = (idx: number) =>
delay / 1000 + (duration / 1000) * STAGGER_COEFF * (totalDigits - idx);
return (
<>
{target.intGroups.map((group, gi) => (
<span key={`g${gi}`} style={{ display: "inline-flex" }}>
{gi > 0 &&
(delimiter ? (
<span aria-hidden="true" className={styles.delimiter} />
) : (
<span aria-hidden="true" className={styles.affix}>
,
</span>
))}
{group.split("").map((ch, ci) => {
digitIndex += 1;
const idx = digitIndex;
return (
<DigitDrum
key={`g${gi}d${ci}`}
target={Number(ch)}
delaySec={delayFor(idx)}
direction={direction}
digitHeight={digitHeight}
flash={flash}
settleKey={settleKey}
/>
);
})}
</span>
))}
{target.fracDigits.length > 0 && (
<>
<span aria-hidden="true" className={styles.affix}>
.
</span>
{target.fracDigits.map((ch, ci) => {
digitIndex += 1;
const idx = digitIndex;
return (
<DigitDrum
key={`f${ci}`}
target={Number(ch)}
delaySec={delayFor(idx)}
direction={direction}
digitHeight={digitHeight}
flash={flash}
settleKey={settleKey}
/>
);
})}
</>
)}
</>
);
}
Dependencies
- motion
- @bottega/tokens