Animated Number

Odometer digits that roll up or down to each new value, with Intl locale and currency formatting and a gain/loss flash.

  • Text Effects
  • Fintech / Crypto
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Reduced motion swaps digits without rolling.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/animated-number.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/animated-number.tsx
1/**
2 * AnimatedNumber — an odometer. Each digit is its own drum that rolls to the
3 * new value; rises roll upward and falls roll downward (wrapping through 0
4 * the way a real counter does). Formatting is Intl.NumberFormat, so locale
5 * separators, currencies, percents and compact notation all just work.
6 * On change the number flashes green (up) or red (down).
7 *
8 * Props
9 * - value: number
10 * - locale?: string Default "en-US".
11 * - format?: Intl.NumberFormatOptions e.g. { style: "currency", currency: "EUR" }.
12 * - duration?: number Roll duration in ms. Default 700.
13 * - flash?: boolean Colour flash on change. Default true.
14 * - signColor?: boolean Keep text green/red by sign (for deltas). Default false.
15 * - className / style Typography is yours; use tabular figures.
16 * CSS vars: --fd-an-up (#16A34A), --fd-an-down (#DC2626), --fd-an-dur.
17 *
18 * Accessibility: the formatted value is real text in a visually hidden span;
19 * the drums are aria-hidden. Don't put aria-live on fast tickers — announce
20 * summaries instead (see the Demo).
21 * Reduced motion: digits swap without rolling; the flash is a short fade.
22 * Browser support: all evergreen browsers.
23 */
24import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
25import type { CSSProperties } from "react";
26
27const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
28const STRIP = Array.from({ length: 30 }, (_, i) => i % 10);
29
30function Digit({ d, dir, duration }: { d: number; dir: number; duration: number }) {
31 const strip = useRef<HTMLSpanElement>(null);
32 const pos = useRef(10 + d);
33 const timer = useRef(0);
34 useIso(() => {
35 const el = strip.current;
36 if (!el) return;
37 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
38 const cur = pos.current % 10;
39 if (cur === d) return;
40 window.clearTimeout(timer.current);
41 if (reduced) {
42 el.style.transition = "none";
43 pos.current = 10 + d;
44 el.style.transform = `translate3d(0,${-pos.current}em,0)`;
45 return;
46 }
47 // normalise to the middle copy without animating, then roll in `dir`
48 el.style.transition = "none";
49 el.style.transform = `translate3d(0,${-(10 + cur)}em,0)`;
50 void el.offsetHeight;
51 let target = 10 + d;
52 if (dir > 0 && d <= cur) target = 20 + d;
53 if (dir < 0 && d >= cur) target = d;
54 el.style.transition = `transform ${duration}ms cubic-bezier(.22,1,.36,1)`;
55 el.style.transform = `translate3d(0,${-target}em,0)`;
56 pos.current = target;
57 timer.current = window.setTimeout(() => {
58 el.style.transition = "none";
59 pos.current = 10 + d;
60 el.style.transform = `translate3d(0,${-pos.current}em,0)`;
61 }, duration + 30);
62 }, [d, dir, duration]);
63 useEffect(() => () => window.clearTimeout(timer.current), []);
64 return (
65 <span className="fd-an-d">
66 <span className="fd-an-ghost">0</span>
67 <span className="fd-an-strip" ref={strip} style={{ transform: `translate3d(0,${-(10 + d)}em,0)` }}>
68 {STRIP.map((n, i) => (
69 <span key={i} className="fd-an-n">{n}</span>
70 ))}
71 </span>
72 </span>
73 );
74}
75
76export interface AnimatedNumberProps {
77 value: number;
78 locale?: string;
79 format?: Intl.NumberFormatOptions;
80 duration?: number;
81 flash?: boolean;
82 signColor?: boolean;
83 className?: string;
84 style?: CSSProperties;
85}
86
87export function AnimatedNumber({ value, locale = "en-US", format, duration = 700, flash = true, signColor = false, className, style }: AnimatedNumberProps) {
88 const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, JSON.stringify(format)]); // eslint-disable-line react-hooks/exhaustive-deps
89 const text = fmt.format(value);
90 // derive direction during render so digits know it on the same commit
91 const [prev, setPrev] = useState(value);
92 const [dir, setDir] = useState(0);
93 const [tick, setTick] = useState(0);
94 if (value !== prev) {
95 setPrev(value);
96 setDir(value > prev ? 1 : -1);
97 setTick((t) => t + 1);
98 }
99
100 // key characters from the right so columns persist when the length changes
101 const chars = Array.from(text);
102 const n = chars.length;
103 return (
104 <span
105 className={`fd-an${className ? " " + className : ""}`}
106 style={{ ["--fd-an-dur" as string]: `${duration}ms`, ...style }}
107 data-dir={dir > 0 ? "up" : dir < 0 ? "down" : undefined}
108 data-sign={signColor ? (value > 0 ? "pos" : value < 0 ? "neg" : "zero") : undefined}
109 >
110 <style>{css}</style>
111 <span className="fd-an-sr">{text}</span>
112 <span className="fd-an-row" aria-hidden="true" data-flash={flash && tick > 0 ? (tick % 2 ? "a" : "b") : undefined}>
113 {chars.map((ch, i) => {
114 const k = n - i;
115 return /\d/.test(ch) ? (
116 <Digit key={`d${k}`} d={Number(ch)} dir={dir} duration={duration} />
117 ) : (
118 <span key={`s${k}-${ch}`} className="fd-an-s">{ch}</span>
119 );
120 })}
121 </span>
122 </span>
123 );
124}
125
126const css = `
127.fd-an,.fd-an *{box-sizing:border-box}
128.fd-an{--fd-an-up:#16A34A;--fd-an-down:#DC2626;display:inline-block;margin:0;padding:0;font-variant-numeric:tabular-nums;font-feature-settings:"tnum";white-space:nowrap;vertical-align:baseline}
129.fd-an-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
130.fd-an-row{display:inline-flex;margin:0;padding:0;line-height:1;color:inherit}
131.fd-an[data-sign="pos"] .fd-an-row{color:var(--fd-an-up)}
132.fd-an[data-sign="neg"] .fd-an-row{color:var(--fd-an-down)}
133.fd-an[data-dir="up"] .fd-an-row[data-flash="a"]{animation:fd-an-up-a calc(var(--fd-an-dur) + 500ms) cubic-bezier(.22,1,.36,1)}
134.fd-an[data-dir="up"] .fd-an-row[data-flash="b"]{animation:fd-an-up-b calc(var(--fd-an-dur) + 500ms) cubic-bezier(.22,1,.36,1)}
135.fd-an[data-dir="down"] .fd-an-row[data-flash="a"]{animation:fd-an-down-a calc(var(--fd-an-dur) + 500ms) cubic-bezier(.22,1,.36,1)}
136.fd-an[data-dir="down"] .fd-an-row[data-flash="b"]{animation:fd-an-down-b calc(var(--fd-an-dur) + 500ms) cubic-bezier(.22,1,.36,1)}
137@keyframes fd-an-up-a{0%,30%{color:var(--fd-an-up)}}
138@keyframes fd-an-up-b{0%,30%{color:var(--fd-an-up)}}
139@keyframes fd-an-down-a{0%,30%{color:var(--fd-an-down)}}
140@keyframes fd-an-down-b{0%,30%{color:var(--fd-an-down)}}
141.fd-an-d{position:relative;display:inline-block;margin:0;padding:0;height:1em;overflow:hidden;
142 -webkit-mask-image:linear-gradient(transparent 0,#000 18%,#000 82%,transparent 100%);mask-image:linear-gradient(transparent 0,#000 18%,#000 82%,transparent 100%)}
143.fd-an-ghost{visibility:hidden;display:inline-block;margin:0;padding:0}
144.fd-an-strip{position:absolute;left:0;right:0;top:0;display:flex;flex-direction:column;margin:0;padding:0;will-change:transform}
145.fd-an-n{display:block;height:1em;margin:0;padding:0;text-align:center}
146.fd-an-s{display:inline-block;margin:0;padding:0;white-space:pre}
147@media (prefers-reduced-motion:reduce){
148 .fd-an[data-dir] .fd-an-row[data-flash]{animation-duration:120ms}
149 .fd-an-d{-webkit-mask-image:none;mask-image:none}
150}
151`;
152
153/* ───────────────────────────── demo ───────────────────────────── */
154
155type Asset = { sym: string; name: string; price: number; open: number; hold: number; color: string; vol: number };
156
157const START: Asset[] = [
158 { sym: "BTC", name: "Bitcoin", price: 98412.55, open: 96870.1, hold: 0.4182, color: "#F7931A", vol: 0.0016 },
159 { sym: "ETH", name: "Ether", price: 3641.28, open: 3702.9, hold: 6.25, color: "#8C8CF6", vol: 0.0022 },
160 { sym: "SOL", name: "Solana", price: 212.47, open: 205.12, hold: 84, color: "#2DD4BF", vol: 0.0035 },
161 { sym: "LINK", name: "Chainlink", price: 24.083, open: 24.51, hold: 640, color: "#3B82F6", vol: 0.004 },
162 { sym: "EURC", name: "Euro Coin", price: 1.0842, open: 1.0839, hold: 12500, color: "#A3E635", vol: 0.0002 },
163];
164
165export function Demo() {
166 const [assets, setAssets] = useState(START);
167 const [live, setLive] = useState(true);
168 const [ccy, setCcy] = useState<"USD" | "EUR">("USD");
169 const fx = ccy === "USD" ? 1 : 0.9223;
170
171 useEffect(() => {
172 if (!live) return;
173 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
174 let seed = 7;
175 const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) * 2 - 1;
176 const id = window.setInterval(() => {
177 if (document.hidden) return;
178 setAssets((as) =>
179 as.map((a, i) => {
180 if (rnd() < -0.2 && i !== 0) return a; // not every asset ticks every time
181 const p = a.price * (1 + rnd() * a.vol * 2.2);
182 const dp = a.price > 1000 ? 2 : a.price > 10 ? 2 : 4;
183 return { ...a, price: Number(p.toFixed(dp)) };
184 }),
185 );
186 }, 1700);
187 return () => window.clearInterval(id);
188 }, [live]);
189
190 const total = assets.reduce((s, a) => s + a.price * a.hold, 0) * fx;
191 const opened = assets.reduce((s, a) => s + a.open * a.hold, 0) * fx;
192 const pnl = total - opened;
193 const money = (max: number): Intl.NumberFormatOptions => ({ style: "currency", currency: ccy, minimumFractionDigits: 2, maximumFractionDigits: max });
194
195 return (
196 <div className="fd-an-demo">
197 <style>{demoCss}</style>
198 <div className="fd-an-demo-card">
199 <header className="fd-an-demo-head">
200 <span className="fd-an-demo-brand">
201 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="6" fill="#0E1116" /><path d="M7 15l3.2-4 2.6 2.4L17 8" fill="none" stroke="#5EEAD4" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
202 Ledgerline
203 </span>
204 <div className="fd-an-demo-tools">
205 <div className="fd-an-demo-seg" role="radiogroup" aria-label="Display currency">
206 {(["USD", "EUR"] as const).map((c) => (
207 <button key={c} type="button" role="radio" aria-checked={ccy === c} onClick={() => setCcy(c)}>{c}</button>
208 ))}
209 </div>
210 <button type="button" className="fd-an-demo-live" aria-pressed={live} onClick={() => setLive((l) => !l)}>
211 <span aria-hidden="true" data-on={live ? "" : undefined} />
212 {live ? "Live" : "Paused"}
213 </button>
214 </div>
215 </header>
216
217 <section className="fd-an-demo-total" aria-label="Portfolio value">
218 <p className="fd-an-demo-k">Portfolio value</p>
219 <AnimatedNumber value={total} format={money(2)} locale={ccy === "USD" ? "en-US" : "de-DE"} className="fd-an-demo-big" duration={900} />
220 <p className="fd-an-demo-pnl">
221 <AnimatedNumber value={pnl} format={{ ...money(2), signDisplay: "always" }} locale={ccy === "USD" ? "en-US" : "de-DE"} signColor flash={false} />
222 <span className="fd-an-demo-dot" aria-hidden="true">·</span>
223 <AnimatedNumber value={pnl / opened} format={{ style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "always" }} signColor flash={false} />
224 <span className="fd-an-demo-today">today</span>
225 </p>
226 </section>
227
228 <table className="fd-an-demo-table">
229 <caption className="fd-an-sr">Holdings, prices update every two seconds</caption>
230 <thead>
231 <tr><th scope="col">Asset</th><th scope="col">Price</th><th scope="col">24h</th><th scope="col">Holding</th></tr>
232 </thead>
233 <tbody>
234 {assets.map((a) => {
235 const ch = (a.price - a.open) / a.open;
236 const p = a.price * fx;
237 const frac = a.price < 10 ? 4 : a.price < 100 ? 3 : 2;
238 return (
239 <tr key={a.sym}>
240 <th scope="row">
241 <span className="fd-an-demo-coin" style={{ background: a.color }} aria-hidden="true">{a.sym[0]}</span>
242 <span className="fd-an-demo-sym">{a.sym}<small>{a.name}</small></span>
243 </th>
244 <td><AnimatedNumber value={p} format={{ ...money(frac), minimumFractionDigits: frac }} locale={ccy === "USD" ? "en-US" : "de-DE"} /></td>
245 <td><AnimatedNumber value={ch} format={{ style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "always" }} signColor flash={false} className="fd-an-demo-chg" /></td>
246 <td><AnimatedNumber value={p * a.hold} format={money(2)} locale={ccy === "USD" ? "en-US" : "de-DE"} flash={false} className="fd-an-demo-hold" /></td>
247 </tr>
248 );
249 })}
250 </tbody>
251 </table>
252 <p className="fd-an-demo-foot">Prices are indicative mid-market quotes. Not investment advice.</p>
253 </div>
254 </div>
255 );
256}
257
258const demoCss = `
259.fd-an-demo,.fd-an-demo *,.fd-an-demo *::before,.fd-an-demo *::after{box-sizing:border-box}
260.fd-an-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
261 --d-ink:#ECEFF3;--d-mut:#8A93A0;--d-line:rgba(236,239,243,.08);
262 display:grid;place-items:center;margin:0;padding:clamp(16px,4vw,48px) clamp(16px,4vw,48px);min-height:100vh;
263 background:radial-gradient(80% 60% at 50% 0%,#14202A 0%,#080A0D 70%);color:var(--d-ink);font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
264.fd-an-demo .fd-an{--fd-an-up:#34D399;--fd-an-down:#F87171}
265.fd-an-demo .fd-an-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
266.fd-an-demo-card{width:100%;max-width:820px;margin:0;padding:clamp(18px,3vw,32px);border-radius:28px;background:rgba(16,20,26,.85);
267 box-shadow:inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(255,255,255,.05),0 40px 80px -40px rgba(0,0,0,.8)}
268.fd-an-demo-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:0}
269.fd-an-demo-brand{display:inline-flex;align-items:center;gap:10px;margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em}
270.fd-an-demo-brand svg{display:block}
271.fd-an-demo-tools{display:flex;gap:8px;margin:0}
272.fd-an-demo-seg{display:inline-flex;margin:0;padding:3px;border-radius:9999px;background:rgba(255,255,255,.06)}
273.fd-an-demo-seg button,.fd-an-demo-live{appearance:none;-webkit-appearance:none;margin:0;height:30px;padding:0 12px;border:0;border-radius:9999px;background:transparent;color:var(--d-mut);
274 font:500 13px/1 var(--d-font);cursor:pointer;transition:background-color 240ms cubic-bezier(.65,0,.35,1),color 240ms}
275.fd-an-demo-seg button[aria-checked="true"]{background:rgba(255,255,255,.12);color:var(--d-ink)}
276.fd-an-demo-live{display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 14px;background:rgba(255,255,255,.06)}
277.fd-an-demo-live span{display:block;width:7px;height:7px;border-radius:50%;background:var(--d-mut)}
278.fd-an-demo-live span[data-on]{background:#34D399;box-shadow:0 0 0 3px rgba(52,211,153,.2)}
279.fd-an-demo button:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:2px}
280.fd-an-demo-total{margin:clamp(24px,4vh,40px) 0 0}
281.fd-an-demo-k{margin:0 0 10px;color:var(--d-mut);font:500 13px/1 var(--d-font)}
282.fd-an-demo-big{color:var(--d-ink);font:600 clamp(40px,7vw,72px)/1 var(--d-disp);letter-spacing:-.04em}
283.fd-an-demo-pnl{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px;margin:14px 0 0;font:500 15px/1.2 var(--d-font)}
284.fd-an-demo-dot,.fd-an-demo-today{color:var(--d-mut)}
285.fd-an-demo-table{width:100%;margin:clamp(24px,4vh,40px) 0 0;border-collapse:collapse;border:0;font:400 15px/1.2 var(--d-font)}
286.fd-an-demo-table thead th{margin:0;padding:0 0 10px;border:0;border-bottom:1px solid var(--d-line);color:var(--d-mut);font:500 12px/1 var(--d-font);text-transform:uppercase;letter-spacing:.06em;text-align:right}
287.fd-an-demo-table thead th:first-child{text-align:left}
288.fd-an-demo-table tbody th,.fd-an-demo-table td{margin:0;padding:14px 0;border:0;border-bottom:1px solid var(--d-line);text-align:right;font:500 15px/1.2 var(--d-font);color:var(--d-ink)}
289.fd-an-demo-table tbody tr:last-child th,.fd-an-demo-table tbody tr:last-child td{border-bottom:0}
290.fd-an-demo-table tbody th{display:flex;align-items:center;gap:12px;text-align:left}
291.fd-an-demo-coin{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:50%;color:#0B0D10;font:700 13px/1 var(--d-font)}
292.fd-an-demo-sym{display:flex;flex-direction:column;gap:3px;margin:0;font:600 15px/1 var(--d-font)}
293.fd-an-demo-sym small{color:var(--d-mut);font:400 13px/1 var(--d-font)}
294.fd-an-demo-chg{font-size:13px}
295.fd-an-demo-hold{color:var(--d-mut)}
296.fd-an-demo-foot{margin:18px 0 0;color:rgba(138,147,160,.8);font:400 12px/1.4 var(--d-font)}
297@media (max-width:560px){.fd-an-demo-table thead th:nth-child(4),.fd-an-demo-table td:nth-child(4){display:none}}
298`;

More Text Effects. Same file contract.

Split Text

Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.

Text Morph

Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.

Text Repel

A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.