"use client";
import * as React from "react";
export interface MetricTickerProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Current value. For `format="percent"` pass a fraction (0.124 → 12.4%). */
value: number;
label: string;
format?: "number" | "currency" | "percent";
/** ISO 4217 code used when `format="currency"`. */
currency?: string;
/** Fixed number of fraction digits. */
decimals?: number;
locale?: string;
/**
* Relative change to display (0.024 → +2.4%). When omitted, the change from the
* previous `value` is used.
*/
delta?: number;
/** Treat decreases as good (e.g. latency, cost, churn). */
invertDelta?: boolean;
/** Rolling odometer digits or a tweened number. */
animation?: "roll" | "tween";
/** Transition length in milliseconds. */
duration?: number;
size?: "sm" | "md" | "lg";
/** Draw a sparkline of recent values. */
sparkline?: boolean;
/** Values to seed the sparkline with (oldest first). Later updates are appended. */
history?: number[];
/** Number of points kept for the sparkline. */
historyLength?: number;
/** Sparkline colour. */
accent?: string;
/** Small text under the value, e.g. "vs. last update". */
caption?: string;
/** Announce updates through a polite live region. */
announce?: boolean;
}
const SIZES = {
sm: { value: "text-2xl", gap: "gap-1.5" },
md: { value: "text-4xl", gap: "gap-2" },
lg: { value: "text-5xl sm:text-6xl", gap: "gap-2.5" },
} as const;
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
const ROW = 1.1; // em — line box of a single digit
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** One odometer wheel. The transform transition always runs from the previous digit to the new one. */
function RollingDigit({ digit, duration }: { digit: number; duration: number }) {
return (
<span className="relative inline-block overflow-hidden" style={{ height: `${ROW}em`, lineHeight: ROW }}>
<span className="invisible">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col items-center transition-transform ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${-digit * ROW}em)`, transitionDuration: `${duration}ms` }}
>
{DIGITS.map((d) => (
<span key={d} style={{ height: `${ROW}em` }}>
{d}
</span>
))}
</span>
</span>
);
}
function sparkPath(values: number[]) {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const pts = values.map((v, i) => [(i / (values.length - 1)) * 100, 28 - ((v - min) / span) * 24] as const);
const line = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");
return { line, area: `${line}L100,32L0,32Z`, last: pts[pts.length - 1] };
}
/**
* A dashboard metric that transitions honestly: every update animates from the previous value
* to the new one (never from zero), with a delta badge that uses arrow + sign + colour.
*/
export function MetricTicker({
value,
label,
format = "number",
currency = "USD",
decimals = 0,
locale = "en-US",
delta,
invertDelta = false,
animation = "roll",
duration = 600,
size = "md",
sparkline = true,
history,
historyLength = 24,
accent = "#FF7448",
caption,
announce = true,
className = "",
style,
...props
}: MetricTickerProps) {
const reduceMotion = usePrefersReducedMotion();
const labelId = React.useId();
const valueRef = React.useRef<HTMLSpanElement>(null);
const tweenRef = React.useRef<HTMLSpanElement>(null);
const shownRef = React.useRef(value);
// Track the previous value and a rolling history by comparing during render (no effect round-trip).
const [series, setSeries] = React.useState(() => ({
last: value,
previous: value,
updates: 0,
points: [...(history ?? []), value].slice(-historyLength),
}));
if (series.last !== value) {
setSeries({
last: value,
previous: series.last,
updates: series.updates + 1,
points: [...series.points, value].slice(-historyLength),
});
}
const formatter = React.useMemo(() => {
const digits = Math.max(0, Math.min(20, Math.round(decimals)));
const options: Intl.NumberFormatOptions = {
style: format === "currency" ? "currency" : format === "percent" ? "percent" : "decimal",
minimumFractionDigits: digits,
maximumFractionDigits: digits,
};
if (format === "currency") options.currency = currency;
try {
return new Intl.NumberFormat(locale, options);
} catch {
return new Intl.NumberFormat("en-US", { ...options, currency: format === "currency" ? "USD" : undefined });
}
}, [format, currency, decimals, locale]);
const deltaFormatter = React.useMemo(() => {
try {
return new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 });
} catch {
return new Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}, [locale]);
const formatted = formatter.format(value);
const change =
delta ?? (series.previous === 0 ? 0 : (value - series.previous) / Math.abs(series.previous));
const direction = change > 0 ? "up" : change < 0 ? "down" : "flat";
const good = direction === "flat" ? null : invertDelta ? direction === "down" : direction === "up";
const tone =
good === null
? "text-zinc-500 bg-zinc-900/5 dark:text-zinc-400 dark:bg-white/5"
: good
? "text-emerald-700 bg-emerald-500/10 dark:text-emerald-300 dark:bg-emerald-400/10"
: "text-rose-700 bg-rose-500/10 dark:text-rose-300 dark:bg-rose-400/10";
const deltaText = `${change > 0 ? "+" : change < 0 ? "−" : "±"}${deltaFormatter.format(Math.abs(change))}`;
// Tween mode: write frames straight to the DOM, starting from the number currently shown.
// A layout effect restores the start value before paint, so the new value never flashes first.
React.useLayoutEffect(() => {
if (animation !== "tween") {
shownRef.current = value;
return;
}
const node = tweenRef.current;
if (!node) return;
const from = shownRef.current;
if (reduceMotion || duration <= 0 || from === value) {
shownRef.current = value;
node.textContent = formatter.format(value);
return;
}
node.textContent = formatter.format(from);
let frame = 0;
let start: number | null = null;
const tick = (now: number) => {
if (start === null) start = now;
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const current = t === 1 ? value : from + (value - from) * eased;
shownRef.current = current;
node.textContent = formatter.format(current);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [value, animation, duration, reduceMotion, formatter]);
// A brief directional wash on update (skipped on mount and with reduced motion).
const updates = series.updates;
React.useEffect(() => {
const node = valueRef.current;
if (!node || updates === 0 || reduceMotion || good === null || typeof node.animate !== "function") return;
const colour = good ? "rgba(16, 185, 129, 0.16)" : "rgba(244, 63, 94, 0.16)";
const anim = node.animate([{ backgroundColor: colour }, { backgroundColor: "rgba(0, 0, 0, 0)" }], {
duration: Math.max(500, duration * 1.6),
easing: "ease-out",
});
return () => anim.cancel();
}, [updates, good, reduceMotion, duration]);
const sizes = SIZES[size] ?? SIZES.md;
const chars = formatted.split("");
const spark = sparkline && series.points.length > 1 ? sparkPath(series.points) : null;
const rollDuration = reduceMotion ? 0 : duration;
return (
<div
role="group"
aria-labelledby={labelId}
className={`flex flex-col ${sizes.gap} text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--mt-accent" as string]: accent, ...style }}
{...props}
>
<div className="flex items-center justify-between gap-3">
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[11px] tabular-nums ${tone}`}>
<svg viewBox="0 0 10 10" className="size-2.5" aria-hidden="true">
{direction === "flat" ? (
<rect x="1.5" y="4.4" width="7" height="1.2" rx="0.6" fill="currentColor" />
) : (
<path d={direction === "up" ? "M5 1.5 8.5 7h-7z" : "M5 8.5 1.5 3h7z"} fill="currentColor" />
)}
</svg>
<span aria-hidden="true">{deltaText}</span>
<span className="sr-only">
{direction === "flat" ? "unchanged" : `${direction} ${deltaFormatter.format(Math.abs(change))}`}
{good === null ? "" : good ? " (favourable)" : " (unfavourable)"}
</span>
</span>
</div>
<span
ref={valueRef}
aria-hidden="true"
className={`-mx-1 inline-flex w-fit items-baseline rounded-md px-1 font-semibold tracking-[-0.035em] whitespace-nowrap tabular-nums ${sizes.value}`}
>
{animation === "roll" ? (
// Keys count from the right so units, tens… keep their wheel when the length changes.
chars.map((ch, i) => {
const key = chars.length - i;
return /\d/.test(ch) ? (
<RollingDigit key={key} digit={Number(ch)} duration={rollDuration} />
) : (
<span key={key} style={{ lineHeight: ROW }}>
{ch}
</span>
);
})
) : (
<span ref={tweenRef} style={{ lineHeight: ROW }} suppressHydrationWarning>
{formatted}
</span>
)}
</span>
<span className="sr-only">{formatted}</span>
{spark ? (
<div className="relative h-8 w-full" aria-hidden="true">
<svg viewBox="0 0 100 32" preserveAspectRatio="none" className="absolute inset-0 h-full w-full overflow-visible">
<path d={spark.area} fill="var(--mt-accent)" opacity={0.12} />
<path d={spark.line} fill="none" stroke="var(--mt-accent)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
</svg>
<span
className="absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--mt-accent)] ring-2 ring-white dark:ring-zinc-950"
style={{ left: `${spark.last[0]}%`, top: `${(spark.last[1] / 32) * 100}%` }}
/>
</div>
) : null}
{caption ? <span className="text-xs text-zinc-500 dark:text-zinc-400">{caption}</span> : null}
{announce ? (
<span className="sr-only" aria-live="polite" aria-atomic="true">
{series.updates > 0
? `${label} ${formatted}, ${direction === "flat" ? "unchanged" : `${direction} ${deltaFormatter.format(Math.abs(change))}`}`
: ""}
</span>
) : null}
</div>
);
}