Delta Number Ticker for React and Tailwind CSS (shadcn/ui): digits roll on every change while a chip with the absolute and percent change lifts off the number and settles beside it, and the old value lingers below as a ghost. It is built to show how much and which way a figure moved. Columns roll with a 20 ms stagger, new columns grow in for extra digits, and goodDirection paints a falling latency as good. Controlled previousValue or own memory, Intl formatting, polite announcements, reduced motion, light and dark. Use it for KPIs, prices, balances, latency and stock. The included demo plays a fixed storefront scenario and makes no requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Delta Number Ticker – Rolling Number with Animated Change Chip and Previous Value to your project and fits it to your theme.
Code
How Delta Number Ticker – Rolling Number with Animated Change Chip and Previous Value is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { DeltaNumberTicker } from "@/components/ui/delta-number-ticker";
type MetricKey = "revenue" | "sessions" | "latency";
type Values = { [key in MetricKey]: number };
type Step = { at: number; key: MetricKey; delta: number };
type Scene = { initial: Values; steps: Step[]; cycle: number; previous?: { [key in MetricKey]?: number }; seconds?: boolean };
const BASE_STEPS: Step[] = [
{ at: 600, key: "revenue", delta: 1240 },
{ at: 2000, key: "sessions", delta: -37 },
{ at: 3400, key: "latency", delta: -18 },
{ at: 4800, key: "revenue", delta: 880 },
{ at: 6200, key: "sessions", delta: 22 },
{ at: 7600, key: "latency", delta: 9 },
{ at: 9000, key: "revenue", delta: 1320 },
{ at: 10400, key: "sessions", delta: 41 },
{ at: 11800, key: "latency", delta: -6 },
{ at: 13200, key: "revenue", delta: 640 },
{ at: 14600, key: "sessions", delta: -26 },
{ at: 16000, key: "latency", delta: 15 },
];
const DEFAULT: Scene = { initial: { revenue: 82970, sessions: 1321, latency: 230 }, steps: BASE_STEPS, cycle: 16800 };
const SCENES: { [name: string]: Scene } = {
digits: { initial: { revenue: 82970, sessions: 998, latency: 230 }, steps: [{ at: 600, key: "sessions", delta: 5 }, ...BASE_STEPS.slice(0, 1).map((step) => ({ ...step, at: 2000 }))], cycle: 4800 },
shrink: { initial: { revenue: 82970, sessions: 1003, latency: 230 }, steps: [{ at: 600, key: "sessions", delta: -5 }, { at: 2000, key: "revenue", delta: 1240 }], cycle: 4800 },
controlled: { initial: { revenue: 84210, sessions: 1321, latency: 230 }, steps: [{ at: 600, key: "revenue", delta: 2120 }, ...BASE_STEPS.slice(1)], cycle: 16800, previous: { revenue: 82970 } },
flat: { initial: { revenue: 84210, sessions: 1321, latency: 230 }, steps: BASE_STEPS.slice(1), cycle: 16800, previous: { revenue: 84210 } },
fromzero: { initial: { revenue: 82970, sessions: 12, latency: 230 }, steps: BASE_STEPS, cycle: 16800, previous: { sessions: 0 } },
seconds: { initial: { revenue: 82970, sessions: 1321, latency: 3.4 }, steps: [{ at: 600, key: "latency", delta: 0.5 }, { at: 2000, key: "revenue", delta: 1240 }], cycle: 4800, seconds: true },
};
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const CURRENCY: Intl.NumberFormatOptions = { style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 };
const MILLISECONDS: Intl.NumberFormatOptions = { style: "unit", unit: "millisecond", unitDisplay: "short" };
const seconds = (value: number) => `${value.toFixed(1)}s`;
const round = (value: number) => Math.round(value * 100) / 100;
const clockLabel = (index: number) => {
const minutes = 10 * 60 + 42 + index;
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
};
export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
const params = React.useMemo(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search), []);
const scene = React.useMemo(() => SCENES[params.get("scene") ?? ""] ?? DEFAULT, [params]);
const [values, setValues] = React.useState(scene.initial);
const [clock, setClock] = React.useState({} as { [key in MetricKey]?: string });
const [stopped, setStopped] = React.useState(false);
const [settled, setSettled] = React.useState([] as string[]);
const [applied, setApplied] = React.useState(0);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const [entered, setEntered] = React.useState(!autoplay);
const stageRef = React.useRef(null as HTMLElement | null);
const scripted = autoplay && !stopped;
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready || !autoplay) return;
const node = stageRef.current;
if (!motion.reduced && node && typeof node.animate === "function") {
node.animate([{ opacity: 0, transform: "translateY(10px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 420, easing: EASE });
}
setEntered(true);
}, [motion.ready, motion.reduced, autoplay]);
React.useEffect(() => {
if (!scripted) return;
const start = performance.now();
let index = 0;
let cycle = 0;
let timer = 0;
const schedule = () => {
const step = scene.steps[index];
const at = cycle * scene.cycle + (step ? step.at : scene.cycle);
timer = window.setTimeout(() => {
if (step) {
const ordinal = cycle * scene.steps.length + index;
setValues((current) => ({ ...current, [step.key]: round(current[step.key] + step.delta) }));
setClock((current) => ({ ...current, [step.key]: clockLabel(ordinal) }));
setApplied((count) => count + 1);
index += 1;
} else {
index = 0;
cycle += 1;
}
schedule();
}, Math.max(0, at - (performance.now() - start)));
};
schedule();
return () => window.clearTimeout(timer);
}, [scripted, scene]);
const stop = () => { if (scripted) setStopped(true); };
const settle = (key: MetricKey) => (value: number) => setSettled((list) => [...list, `${key}:${value}`]);
const previous = (key: MetricKey) => (scene.previous && scene.previous[key] !== undefined ? { previousValue: scene.previous[key] } : {});
return (
<main
data-demo-root
data-stopped={stopped ? "true" : "false"}
data-settled={settled.join(" ")}
data-applied={applied}
onPointerDownCapture={stop}
onKeyDownCapture={stop}
className="flex w-full items-center justify-center overflow-hidden bg-background px-6 text-foreground"
style={{ height: "min(100svh, 760px)" }}
>
<section
ref={stageRef}
data-stage
aria-label="Storefront overview"
className="w-full max-w-[520px] rounded-2xl bg-card p-6 text-card-foreground shadow-sm ring-1 ring-border"
style={{ opacity: entered ? 1 : 0 }}
>
<header className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-sm font-semibold leading-5">Storefront overview</h2>
<p className="text-xs leading-4 text-muted-foreground">Today · updates as orders land</p>
</div>
<span data-live-dot className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden="true" className="size-1.5 rounded-full bg-chart-2" />
Live
</span>
</header>
<div className="mt-3 divide-y divide-border">
<DeltaNumberTicker label="Revenue today" value={values.revenue} format={CURRENCY} changedAt={clock.revenue} onSettle={settle("revenue")} className="py-4" {...previous("revenue")} />
<DeltaNumberTicker label="Active sessions" value={values.sessions} changedAt={clock.sessions} onSettle={settle("sessions")} className="py-4" {...previous("sessions")} />
<DeltaNumberTicker
label="p95 latency"
value={values.latency}
format={scene.seconds ? seconds : MILLISECONDS}
goodDirection="down"
changedAt={clock.latency}
onSettle={settle("latency")}
className="py-4"
{...previous("latency")}
/>
</div>
</section>
</main>
);
}
















