Tally
A count-up number built from odometer-style digit drums that spin in glowing amber and cool to white as they seat, with a brief ember-red underline flare on settle. Locale and currency aware via Intl.NumberFormat.
motionfree
"use client";
import { motion, type HTMLMotionProps, type Variants } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface TallyProps
extends Omit<HTMLMotionProps<"span">, "children" | "initial" | "whileInView" | "viewport" | "transition"> {
/** The number counted up (or down) to. */
value: number;
/** Starting value each digit drum spins up (or down) from. @default 0 */
start?: number;
/** Decimal places to show. @default 0 */
decimals?: number;
/** BCP 47 locale passed to Intl.NumberFormat. @default "en-US" */
locale?: string;
/** Extra Intl.NumberFormat options, e.g. `{ style: "currency", currency: "USD" }`. */
formatOptions?: Intl.NumberFormatOptions;
/** Seconds each digit drum takes to settle. @default 1.4 */
duration?: number;
/** Seconds between each digit's settle, tail to head. @default 0.06 */
stagger?: number;
/** Seconds before the count-up starts. */
delay?: number;
/** Re-run every time the number re-enters the viewport (default: once). */
repeat?: boolean;
}
const DIGIT_ROWS = "0123456789".split("");
function formatValue(
value: number,
decimals: number,
locale: string | undefined,
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
...options,
}).format(value);
}
/**
* Tally — a count-up number built from odometer-style digit drums. Each
* digit enters glowing molten amber and cools to white as it seats when the
* number scrolls into view; the settled value emits a brief ember-red
* underline flare. Falls back to the plain formatted number under reduced
* motion.
*/
export function Tally({
value,
start = 0,
decimals = 0,
locale = "en-US",
formatOptions,
duration = 1.4,
stagger = 0.06,
delay = 0,
repeat = false,
className,
...props
}: TallyProps) {
const reducedMotion = useReducedMotion();
const targetStr = React.useMemo(
() => formatValue(value, decimals, locale, formatOptions),
[value, decimals, locale, formatOptions]
);
const chars = React.useMemo(() => [...targetStr], [targetStr]);
const totalDigits = React.useMemo(() => (targetStr.match(/[0-9]/g) ?? []).length, [targetStr]);
// Right-aligned so each drum starts on the matching digit of `start` (or "0" beyond its length).
const startDigitsFromEnd = React.useMemo(() => {
const startStr = formatValue(start, decimals, locale, formatOptions);
return (startStr.match(/[0-9]/g) ?? []).reverse();
}, [start, decimals, locale, formatOptions]);
if (reducedMotion) {
return (
<motion.span
className={cn("inline-flex tabular-nums", className)}
aria-label={targetStr}
{...props}
>
<span aria-hidden>{targetStr}</span>
</motion.span>
);
}
type DrumCustom = { digit: number; startDigit: number };
const drumVariants: Variants = {
hidden: (custom: DrumCustom) => ({
y: `-${custom.startDigit}em`,
color: "#ff6b35",
filter: "brightness(1.5) saturate(1.3)",
}),
visible: (custom: DrumCustom) => ({
y: `-${custom.digit}em`,
color: "#f5f5f4",
filter: "brightness(1) saturate(1)",
transition: { duration, ease: [0.16, 1, 0.3, 1] },
}),
};
const glyphVariants: Variants = {
hidden: { opacity: 0, y: "0.25em" },
visible: { opacity: 1, y: "0em", transition: { duration: duration * 0.4, ease: "easeOut" } },
};
const underlineVariants: Variants = {
hidden: { opacity: 0, scaleX: 0 },
visible: {
opacity: [0, 1, 0],
scaleX: [0.3, 1, 1],
transition: { delay: duration * 0.9, duration: 0.6, ease: "easeOut" },
},
};
return (
<motion.span
className={cn("relative inline-flex items-baseline tabular-nums", className)}
initial="hidden"
whileInView="visible"
viewport={{ once: !repeat, amount: 0.6 }}
transition={{ staggerChildren: stagger, delayChildren: delay }}
aria-label={targetStr}
{...props}
>
{(() => {
let digitsSeen = 0;
return chars.map((char, i) => {
const digit = Number(char);
const isDigit = /[0-9]/.test(char) && !Number.isNaN(digit);
if (!isDigit) {
return (
<motion.span key={i} aria-hidden variants={glyphVariants} className="inline-block">
{char}
</motion.span>
);
}
const distanceFromEnd = totalDigits - digitsSeen - 1;
digitsSeen += 1;
const startDigit = Number(startDigitsFromEnd[distanceFromEnd] ?? "0");
return (
<span
key={i}
aria-hidden
// clip-path (not overflow:hidden) clips the reel: overflow other
// than visible re-baselines an inline-block to its bottom margin
// edge, floating digits ~0.2em above sibling "$"/","/"." glyphs.
// The invisible in-flow strut supplies the true text baseline
// (and the digit's natural width), and lineHeight:1em sizes the
// cell to exactly one reel row so neighbors never peek through.
className="relative inline-block align-baseline"
style={{ lineHeight: "1em", clipPath: "inset(0)" }}
>
<span className="invisible">{char}</span>
<motion.span
custom={{ digit, startDigit }}
variants={drumVariants}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-transform"
>
{DIGIT_ROWS.map((d) => (
<span key={d} className="block text-center" style={{ height: "1em", lineHeight: "1em" }}>
{d}
</span>
))}
</motion.span>
</span>
);
});
})()}
<motion.span
aria-hidden
variants={underlineVariants}
className="pointer-events-none absolute -bottom-1 left-0 h-[2px] w-full origin-left rounded-full"
style={{
background: "linear-gradient(90deg, transparent, #c1121f, transparent)",
boxShadow: "0 0 8px #c1121f",
}}
/>
</motion.span>
);
}
Installation
CLI
npx shadcn@latest add @crucible/tallyManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.