Split Text
Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
Odometer digits that roll up or down to each new value, with Intl locale and currency formatting and a gain/loss flash.
npx shadcn@latest add https://freedesignmd.com/r/animated-number.json1/**2 * AnimatedNumber — an odometer. Each digit is its own drum that rolls to the3 * new value; rises roll upward and falls roll downward (wrapping through 04 * the way a real counter does). Formatting is Intl.NumberFormat, so locale5 * separators, currencies, percents and compact notation all just work.6 * On change the number flashes green (up) or red (down).7 *8 * Props9 * - value: number10 * - 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 — announce20 * 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-deps89 const text = fmt.format(value);90 // derive direction during render so digits know it on the same commit91 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 changes101 const chars = Array.from(text);102 const n = chars.length;103 return (104 <span105 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 time181 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 Ledgerline203 </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`;Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.
A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.