Count-up Stats

A stat band whose numbers roll up like an odometer as it enters view, with sparklines that draw on in sync.

  • Scroll & Hover Effects
  • Logistics fintech
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Delta pills use color-mix().

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/count-up-stats.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/count-up-stats.tsx
1/**
2 * CountUpStats — a stat band whose numbers roll up like an odometer when they
3 * scroll into view, each with a sparkline that draws on in sync.
4 *
5 * Components:
6 * - <Odometer value decimals prefix suffix duration delay start /> — each
7 * digit is a vertical reel; reels settle right-to-left with a little
8 * overshoot, like a mechanical counter. Separators (",", ".") are static.
9 * - <StatBand stats /> — lays out stats with label, delta and sparkline and
10 * triggers them once with an IntersectionObserver (35% visible).
11 * Props on stat: { label, value, decimals?, prefix?, suffix?, delta?,
12 * lowerIsBetter? (colours a negative delta green), spark: number[] }.
13 * CSS vars: --fd-cus-accent, --fd-cus-ink, --fd-cus-muted.
14 *
15 * Browser support: evergreen. Only transforms and stroke-dashoffset animate.
16 * Accessibility: the final value is exposed as text (aria-label) from the
17 * first render; reels are aria-hidden. Reduced motion shows final values and
18 * fully drawn sparklines immediately.
19 */
20import { useEffect, useMemo, useRef, useState } from "react";
21import type { CSSProperties } from "react";
22
23export interface OdometerProps {
24 value: number;
25 decimals?: number;
26 prefix?: string;
27 suffix?: string;
28 duration?: number;
29 delay?: number;
30 start?: boolean;
31 locale?: string;
32}
33
34function format(v: number, decimals: number, locale: string) {
35 return v.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
36}
37
38export function Odometer({ value, decimals = 0, prefix = "", suffix = "", duration = 1600, delay = 0, start = true, locale = "en-US" }: OdometerProps) {
39 const text = format(value, decimals, locale);
40 const chars = useMemo(() => text.split(""), [text]);
41 const digitCount = chars.filter((c) => /\d/.test(c)).length;
42 let di = 0;
43 return (
44 <span className="fd-cus-odo" aria-label={`${prefix}${text}${suffix}`} role="text" data-go={start ? "" : undefined}>
45 {prefix && (
46 <span className="fd-cus-affix" aria-hidden="true">
47 {prefix}
48 </span>
49 )}
50 {chars.map((c, i) => {
51 if (!/\d/.test(c))
52 return (
53 <span key={i} className="fd-cus-sep" aria-hidden="true">
54 {c}
55 </span>
56 );
57 const d = Number(c);
58 const fromRight = digitCount - 1 - di++;
59 // like a mechanical counter, the rightmost reels spin the most turns
60 const turns = Math.min(3, fromRight === 0 ? 3 : fromRight === 1 ? 2 : 1);
61 const steps = turns * 10 + d;
62 const dur = duration * (0.62 + 0.38 * (1 - fromRight / Math.max(1, digitCount - 1)));
63 return (
64 <span key={i} className="fd-cus-reel" aria-hidden="true">
65 <span
66 className="fd-cus-strip"
67 style={{
68 ["--to" as string]: `${-steps}em`,
69 ["--dur" as string]: `${Math.round(dur)}ms`,
70 ["--dl" as string]: `${delay + fromRight * 40}ms`,
71 }}
72 >
73 {Array.from({ length: steps + 1 }, (_, k) => (
74 <span key={k} className="fd-cus-d">
75 {k % 10}
76 </span>
77 ))}
78 </span>
79 <span className="fd-cus-ghost">{c}</span>
80 </span>
81 );
82 })}
83 {suffix && (
84 <span className="fd-cus-affix" aria-hidden="true">
85 {suffix}
86 </span>
87 )}
88 </span>
89 );
90}
91
92export type Stat = { label: string; value: number; decimals?: number; prefix?: string; suffix?: string; delta?: string; lowerIsBetter?: boolean; spark: number[] };
93
94export interface StatBandProps {
95 stats: Stat[];
96 className?: string;
97 style?: CSSProperties;
98}
99
100function sparkPath(v: number[], w: number, h: number) {
101 const min = Math.min(...v);
102 const max = Math.max(...v);
103 const pts = v.map((y, i) => [(i / (v.length - 1)) * w, h - 3 - ((y - min) / (max - min || 1)) * (h - 6)] as const);
104 // Catmull-Rom → cubic Bézier for a smooth line
105 let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
106 for (let i = 0; i < pts.length - 1; i++) {
107 const p0 = pts[i - 1] ?? pts[i];
108 const p1 = pts[i];
109 const p2 = pts[i + 1];
110 const p3 = pts[i + 2] ?? p2;
111 const c1x = p1[0] + (p2[0] - p0[0]) / 6;
112 const c1y = p1[1] + (p2[1] - p0[1]) / 6;
113 const c2x = p2[0] - (p3[0] - p1[0]) / 6;
114 const c2y = p2[1] - (p3[1] - p1[1]) / 6;
115 d += ` C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
116 }
117 return { d, end: pts[pts.length - 1] };
118}
119
120export function StatBand({ stats, className, style }: StatBandProps) {
121 const root = useRef<HTMLDivElement>(null);
122 const [go, setGo] = useState(false);
123
124 useEffect(() => {
125 const el = root.current;
126 if (!el) return;
127 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
128 el.dataset.reduced = "";
129 setGo(true);
130 return;
131 }
132 const io = new IntersectionObserver(
133 ([e]) => {
134 if (e.isIntersecting) {
135 setGo(true);
136 io.disconnect();
137 }
138 },
139 { threshold: 0.35 },
140 );
141 io.observe(el);
142 return () => io.disconnect();
143 }, []);
144
145 return (
146 <div ref={root} className={`fd-cus${className ? " " + className : ""}`} data-go={go ? "" : undefined} style={style}>
147 <style>{css}</style>
148 {stats.map((s, i) => {
149 const { d, end } = sparkPath(s.spark, 160, 44);
150 const good = !s.delta || s.delta.trim().startsWith("+") || !!s.lowerIsBetter;
151 const up = !s.delta || !/^[−-]/.test(s.delta.trim());
152 return (
153 <div key={s.label} className="fd-cus-stat" style={{ ["--i" as string]: i }}>
154 <p className="fd-cus-label">{s.label}</p>
155 <p className="fd-cus-value">
156 <Odometer value={s.value} decimals={s.decimals} prefix={s.prefix} suffix={s.suffix} start={go} delay={i * 140} />
157 </p>
158 <div className="fd-cus-row">
159 {s.delta && (
160 <span className="fd-cus-delta" data-good={good ? "" : undefined}>
161 <svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
162 <path d={up ? "M5 1 L9 7 H1 Z" : "M5 9 L9 3 H1 Z"} fill="currentColor" />
163 </svg>
164 {s.delta}
165 </span>
166 )}
167 <svg className="fd-cus-spark" viewBox="-2 -2 168 48" aria-hidden="true">
168 <path className="fd-cus-spark-area" d={`${d} L160 44 L0 44 Z`} />
169 <path className="fd-cus-spark-line" d={d} pathLength={1} />
170 <circle className="fd-cus-spark-dot" cx={end[0]} cy={end[1]} r="3.5" />
171 </svg>
172 </div>
173 </div>
174 );
175 })}
176 </div>
177 );
178}
179
180const css = `
181.fd-cus{
182 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-spring:cubic-bezier(0.34,1.56,0.64,1);
183 --fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
184 --fd-cus-accent:#2F6BFF;--fd-cus-ink:#0B0B0F;--fd-cus-muted:#6B6B76;--fd-cus-up:#0F9D58;--fd-cus-down:#D93025;
185 --fd-cus-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
186 --fd-cus-display:"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter Display","Inter","Segoe UI",Roboto,sans-serif;
187 display:grid;grid-template-columns:var(--fd-cus-cols,repeat(auto-fit,minmax(240px,1fr)));gap:0;margin:0;padding:0;color:var(--fd-cus-ink);
188}
189.fd-cus,.fd-cus *,.fd-cus *::before,.fd-cus *::after{box-sizing:border-box}
190.fd-cus-stat{display:flex;flex-direction:column;gap:10px;margin:0;padding:24px 28px;border-left:1px solid rgba(11,11,15,.1)}
191.fd-cus-stat:first-child{border-left:0}
192.fd-cus-label{margin:0;padding:0;color:var(--fd-cus-muted);font:500 0.8125rem/1.3 var(--fd-cus-sans);letter-spacing:0}
193.fd-cus-value{margin:0;padding:0;color:var(--fd-cus-ink);font:600 clamp(2rem,3.4vw,3.25rem)/1 var(--fd-cus-display);letter-spacing:-0.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
194.fd-cus-odo{display:inline-flex;align-items:flex-end;white-space:nowrap}
195.fd-cus-affix{display:inline-block;white-space:pre;margin:0 .02em;color:inherit;opacity:.5;font-size:.6em;line-height:1.5}
196.fd-cus-sep{display:inline-block;color:inherit}
197.fd-cus-reel{position:relative;display:inline-block;height:1em;overflow:hidden;line-height:1;
198 -webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}
199.fd-cus-ghost{visibility:hidden;display:inline-block}
200.fd-cus-strip{position:absolute;left:0;top:0;display:flex;flex-direction:column;transform:translate3d(0,0,0);will-change:transform}
201.fd-cus-d{display:block;height:1em;line-height:1;text-align:center}
202.fd-cus-odo[data-go] .fd-cus-strip{transform:translate3d(0,var(--to),0);transition:transform var(--dur) cubic-bezier(0.16,1,0.3,1.03) var(--dl)}
203.fd-cus-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin:4px 0 0}
204.fd-cus-delta{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 9px;border-radius:9999px;flex:none;
205 color:var(--fd-cus-down);background:color-mix(in srgb,var(--fd-cus-down) 10%,transparent);font:600 0.75rem/1 var(--fd-cus-sans)}
206.fd-cus-delta[data-good]{color:var(--fd-cus-up);background:color-mix(in srgb,var(--fd-cus-up) 10%,transparent)}
207.fd-cus-spark{display:block;width:min(160px,60%);height:auto;overflow:visible}
208.fd-cus-spark-line{fill:none;stroke:var(--fd-cus-accent);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:1;stroke-dashoffset:1}
209.fd-cus-spark-area{fill:var(--fd-cus-accent);opacity:0}
210.fd-cus-spark-dot{fill:var(--fd-cus-accent);stroke:#fff;stroke-width:2;transform-box:fill-box;transform-origin:center;transform:scale(0)}
211.fd-cus[data-go] .fd-cus-spark-line{stroke-dashoffset:0;transition:stroke-dashoffset 1400ms cubic-bezier(0.65,0,0.35,1) calc(var(--i) * 140ms + 120ms)}
212.fd-cus[data-go] .fd-cus-spark-area{opacity:.08;transition:opacity var(--fd-dur-4) var(--fd-ease-out) calc(var(--i) * 140ms + 900ms)}
213.fd-cus[data-go] .fd-cus-spark-dot{transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring) calc(var(--i) * 140ms + 1450ms)}
214.fd-cus-stat{opacity:0;transform:translate3d(0,14px,0)}
215.fd-cus[data-go] .fd-cus-stat{opacity:1;transform:none;transition:opacity var(--fd-dur-4) var(--fd-ease-out) calc(var(--i) * 90ms),transform var(--fd-dur-4) var(--fd-ease-out) calc(var(--i) * 90ms)}
216@media (max-width:640px){
217 .fd-cus{grid-template-columns:1fr 1fr !important}
218 .fd-cus-stat{padding:18px 14px;border-left:0;border-top:1px solid rgba(11,11,15,.1)}
219 .fd-cus-stat:nth-child(2n){border-left:1px solid rgba(11,11,15,.1)}
220 .fd-cus-stat:nth-child(-n+2){border-top:0}
221 .fd-cus-value{font-size:1.875rem}
222 .fd-cus-row{flex-direction:column;align-items:flex-start}
223 .fd-cus-spark{width:100%}
224}
225.fd-cus[data-reduced] .fd-cus-strip,.fd-cus[data-reduced] .fd-cus-spark-line,.fd-cus[data-reduced] .fd-cus-spark-dot,
226.fd-cus[data-reduced] .fd-cus-stat,.fd-cus[data-reduced] .fd-cus-spark-area{transition:none !important}
227@media (prefers-reduced-motion: reduce){
228 .fd-cus *{transition-duration:0ms !important;transition-delay:0ms !important}
229}
230`;
231
232/* ------------------------------------------------------------------ Demo */
233
234const STATS: Stat[] = [
235 { label: "Shipments tracked this year", value: 48203117, delta: "+18.4% YoY", spark: [12, 14, 13, 17, 19, 18, 22, 24, 23, 27, 30, 33] },
236 { label: "Freight value insured", value: 7.82, decimals: 2, prefix: "$", suffix: "B", delta: "+$1.1B", spark: [5.1, 5.4, 5.3, 5.9, 6.2, 6.1, 6.6, 6.9, 7.0, 7.3, 7.6, 7.82] },
237 { label: "On-time delivery rate", value: 98.7, decimals: 1, suffix: "%", delta: "+2.3 pts", spark: [94.1, 94.8, 95.2, 95.0, 96.1, 96.4, 97.0, 97.2, 97.8, 98.1, 98.3, 98.7] },
238 { label: "Avg. invoice settlement", value: 1.6, decimals: 1, suffix: " days", delta: "−4.2 days", lowerIsBetter: true, spark: [5.8, 5.2, 4.9, 4.1, 3.8, 3.2, 2.9, 2.4, 2.1, 1.9, 1.7, 1.6] },
239];
240
241export function Demo() {
242 return (
243 <div className="fd-cus-demo">
244 <style>{demoCss}</style>
245 <section className="fd-cus-demo-hero">
246 <span className="fd-cus-demo-logo">
247 <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
248 <rect x="2" y="2" width="20" height="20" rx="6" fill="#2F6BFF" />
249 <path d="M7 15 L 11 9 L 14 13 L 17 8" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
250 </svg>
251 Cargora
252 </span>
253 <h1 className="fd-cus-demo-h">Freight payments that settle before the truck is unloaded.</h1>
254 <p className="fd-cus-demo-cue" aria-hidden="true">
255 <span className="fd-cus-demo-cue-line" />
256 Scroll for the numbers
257 </p>
258 </section>
259 <section className="fd-cus-demo-band">
260 <div className="fd-cus-demo-band-head">
261 <span className="fd-cus-demo-k">2026 network report</span>
262 <span className="fd-cus-demo-asof">Updated 28 Oct 2026, 09:00 CET</span>
263 </div>
264 <div className="fd-cus-demo-card">
265 <StatBand stats={STATS} />
266 </div>
267 </section>
268 <section className="fd-cus-demo-after">
269 <p className="fd-cus-demo-p">Trusted by 1,940 carriers and shippers across 31 countries.</p>
270 </section>
271 </div>
272 );
273}
274
275const demoCss = `
276.fd-cus-demo{display:block;margin:0;padding:0;background:#F4F5F8;color:#0B0B0F;min-height:100vh}
277.fd-cus-demo,.fd-cus-demo *{box-sizing:border-box}
278.fd-cus-demo-hero{display:flex;flex-direction:column;min-height:92vh;max-width:1200px;margin:0 auto;padding:28px clamp(16px,4vw,48px) 48px}
279.fd-cus-demo-logo{display:inline-flex;align-items:center;gap:10px;color:#0B0B0F;font:700 1.0625rem/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em}
280.fd-cus-demo-h{margin:auto 0 0;padding:0;max-width:900px;color:#0B0B0F;font:600 clamp(2.25rem,5.6vw,4.5rem)/1.02 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.045em}
281.fd-cus-demo-cue{display:inline-flex;align-items:center;gap:10px;margin:28px 0 0;padding:0;color:#6B6B76;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}
282.fd-cus-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(11,11,15,.15)}
283.fd-cus-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#0B0B0F;animation:fd-cus-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}
284@keyframes fd-cus-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}
285.fd-cus-demo-band{max-width:1200px;margin:0 auto;padding:24px clamp(16px,4vw,48px)}
286.fd-cus-demo-band-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px;margin:0 0 14px;padding:0 4px}
287.fd-cus-demo-k{color:#0B0B0F;font:600 0.9375rem/1.2 ui-sans-serif,-apple-system,"Inter",sans-serif}
288.fd-cus-demo-asof{color:#6B6B76;font:500 0.75rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace}
289.fd-cus-demo-card{--fd-cus-cols:repeat(auto-fit,minmax(240px,1fr));margin:0;padding:12px 0;border-radius:24px;background:#fff;box-shadow:0 1px 0 rgba(11,11,15,.04),0 24px 48px -28px rgba(11,11,15,.18);border:1px solid rgba(11,11,15,.06)}
290.fd-cus-demo-after{max-width:1200px;margin:0 auto;padding:56px clamp(16px,4vw,48px) 50vh}
291.fd-cus-demo-p{margin:0;padding:0;color:#6B6B76;font:400 1.0625rem/1.5 ui-sans-serif,-apple-system,"Inter",sans-serif}
292@media (min-width:1100px){.fd-cus-demo-card{--fd-cus-cols:1.42fr 1fr 1fr 1fr}}
293@media (max-width:640px){.fd-cus-demo-card{padding:4px 0;border-radius:20px}}
294@media (prefers-reduced-motion: reduce){.fd-cus-demo-cue-line::after{animation:none}}
295`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.