Tally
A count-up number built from odometer-style digit drums that enter with a soft white-blue cast and settle to white as they seat, with a brief neutral underline flash 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("");
/** Cool "white-blue" signature cast used on entry (blue-200/300 family). */
const CAST = "#bfdbfe";
/** Vertical cylinder shading painted over each drum face: dark at the top and
* bottom of the slot with a faint specular band above centre. Over the dark
* stage the dark stops vanish; over the bright digit they carve a curved,
* machined drum surface behind the slot. Slate-tinted so the shade stays cool. */
const DRUM_FACE =
"linear-gradient(to bottom," +
"rgba(2,6,23,0.55) 0%," +
"rgba(2,6,23,0) 26%," +
"rgba(255,255,255,0.05) 43%," +
"rgba(2,6,23,0) 60%," +
"rgba(2,6,23,0.6) 100%)";
/** Soft top/bottom fade so digits read as emerging from behind a slot lip
* (and incoming/outgoing digits dissolve at the slot edges as the drum turns). */
const SLOT_MASK = "linear-gradient(to bottom, transparent 0%, #000 11%, #000 89%, transparent 100%)";
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 drum
* enters carrying a cool white-blue cast and a soft bloom, then springs to its
* seated digit and cools to white — the leading drum flaring brightest as it
* seats. A vertical cylinder-shade and a slot fade give every drum physical
* depth behind its window, a low backlight breathes under the readout, and a
* glow sweep races along the baseline the instant the value settles. Falls back
* to the correct static number (softly backlit, faint rest glow) 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
data-crucible="tally"
role="img"
className={cn("relative isolate inline-flex items-baseline tabular-nums", className)}
aria-label={targetStr}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[150%] w-[130%] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
background: "radial-gradient(ellipse at center, rgba(147,197,253,0.12), rgba(147,197,253,0) 70%)",
filter: "blur(6px)",
}}
/>
<span aria-hidden style={{ textShadow: "0 0 8px rgba(191,219,254,0.14)" }}>
{targetStr}
</span>
<span
aria-hidden
className="pointer-events-none absolute -bottom-1 left-0 h-[2px] w-full rounded-full"
style={{
background: "linear-gradient(90deg, transparent, rgba(191,219,254,0.5), transparent)",
boxShadow: "0 0 8px rgba(147,197,253,0.3)",
}}
/>
</motion.span>
);
}
type DrumCustom = { digit: number; startDigit: number; isLead: boolean };
// Bloom lives on a non-clipping wrapper so the drop-shadow escapes the slot
// clip. Lead digit flares brightest right as it seats, then cools to a faint
// resting halo; supporting digits swell more gently.
const bloomVariants: Variants = {
hidden: { filter: "drop-shadow(0 0 9px rgba(147,197,253,0.5))" },
visible: (custom: DrumCustom) => ({
filter: custom.isLead
? [
"drop-shadow(0 0 9px rgba(147,197,253,0.5))",
"drop-shadow(0 0 20px rgba(191,219,254,0.95))",
"drop-shadow(0 0 6px rgba(191,219,254,0.12))",
]
: [
"drop-shadow(0 0 9px rgba(147,197,253,0.45))",
"drop-shadow(0 0 11px rgba(191,219,254,0.5))",
"drop-shadow(0 0 5px rgba(191,219,254,0.1))",
],
transition: { duration, ease: "easeOut", times: custom.isLead ? [0, 0.85, 1] : [0, 0.55, 1] },
}),
};
const drumVariants: Variants = {
hidden: (custom: DrumCustom) => ({ y: `-${custom.startDigit}em`, color: CAST }),
visible: (custom: DrumCustom) => ({
y: `-${custom.digit}em`,
color: "#ffffff",
transition: {
// Spring the reel so it overshoots a hair and settles — the drum's over-roll.
y: { type: "spring", duration, bounce: 0.2 },
color: { duration: duration * 0.75, ease: "easeOut" },
},
}),
};
const glyphVariants: Variants = {
hidden: { opacity: 0, y: "0.25em", color: CAST },
visible: {
opacity: 1,
y: "0em",
color: "#ffffff",
transition: { duration: duration * 0.45, ease: "easeOut" },
},
};
// A bright hotspot sweeping across a mostly-transparent bar: a glint racing
// the baseline, not a flat line. Fades in as it enters, out as it leaves.
const underlineVariants: Variants = {
hidden: { opacity: 0, backgroundPositionX: "120%" },
visible: {
opacity: [0, 1, 1, 0],
backgroundPositionX: ["120%", "-20%"],
transition: {
delay: duration * 0.82,
duration: 0.8,
ease: "easeOut",
opacity: { delay: duration * 0.82, duration: 0.8, times: [0, 0.18, 0.6, 1] },
},
},
};
return (
<motion.span
data-crucible="tally"
role="img"
className={cn("relative isolate 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}
>
<style>{`
@keyframes crucible-tally-breathe {
0%, 100% { opacity: 0.32; transform: translate(-50%, -50%) scale(0.96); }
50% { opacity: 0.68; transform: translate(-50%, -50%) scale(1.05); }
}
[data-crucible="tally"] .tally-backlight {
animation: crucible-tally-breathe 6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="tally"] .tally-backlight { animation: none; opacity: 0.4; }
}
`}</style>
{/* Idle life: a low cool backlight that breathes under the readout. */}
<span
aria-hidden
className="tally-backlight pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[150%] w-[130%] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
background: "radial-gradient(ellipse at center, rgba(147,197,253,0.16), rgba(147,197,253,0) 70%)",
filter: "blur(6px)",
}}
/>
{(() => {
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;
const isLead = digitsSeen === 0;
digitsSeen += 1;
const startDigit = Number(startDigitsFromEnd[distanceFromEnd] ?? "0");
const custom: DrumCustom = { digit, startDigit, isLead };
return (
// Outer wrapper carries the bloom: it has no clip/mask, so the
// drop-shadow glow spreads freely past the slot.
<motion.span
key={i}
aria-hidden
custom={custom}
variants={bloomVariants}
className="inline-block align-baseline will-change-[filter]"
>
<span
// 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.
// The mask fades the slot lips for drum depth.
className="relative inline-block align-baseline"
style={{
lineHeight: "1em",
clipPath: "inset(0)",
maskImage: SLOT_MASK,
WebkitMaskImage: SLOT_MASK,
}}
>
<span className="invisible">{char}</span>
<motion.span
custom={custom}
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>
{/* Cylinder shade over the drum face: machined depth behind the slot. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ background: DRUM_FACE }}
/>
</span>
</motion.span>
);
});
})()}
<motion.span
aria-hidden
variants={underlineVariants}
className="pointer-events-none absolute -bottom-1 left-0 h-[2px] w-full rounded-full"
style={{
background:
"linear-gradient(90deg, transparent 0%, transparent 38%, rgba(191,219,254,0.95) 50%, transparent 62%, transparent 100%)",
backgroundSize: "250% 100%",
boxShadow: "0 0 10px rgba(147,197,253,0.5)",
}}
/>
</motion.span>
);
}
Installation
CLI
npx shadcn@latest add @crucible/tallyManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | The number counted up (or down) to. | |
| start | number | 0 | Starting value each digit drum spins up (or down) from. |
| decimals | number | 0 | Decimal places to show. |
| locale | string | "en-US" | BCP 47 locale passed to Intl.NumberFormat. |
| formatOptions | Intl.NumberFormatOptions | Extra Intl.NumberFormat options, e.g. { style: "currency", currency: "USD" }. | |
| duration | number | 1.4 | Seconds each digit drum takes to settle. |
| stagger | number | 0.06 | Seconds between each digit's settle, tail to head. |
| delay | number | Seconds before the count-up starts. | |
| repeat | boolean | Re-run every time the number re-enters the viewport (default: once). | |
Also accepts all props of Omit<HTMLMotionProps<"span">, "children" | "initial" | "whileInView" | "viewport" | "transition"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.