Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
A stat band whose numbers roll up like an odometer as it enters view, with sparklines that draw on in sync.
npx shadcn@latest add https://freedesignmd.com/r/count-up-stats.json1/**2 * CountUpStats — a stat band whose numbers roll up like an odometer when they3 * scroll into view, each with a sparkline that draws on in sync.4 *5 * Components:6 * - <Odometer value decimals prefix suffix duration delay start /> — each7 * digit is a vertical reel; reels settle right-to-left with a little8 * overshoot, like a mechanical counter. Separators (",", ".") are static.9 * - <StatBand stats /> — lays out stats with label, delta and sparkline and10 * 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 the17 * first render; reels are aria-hidden. Reduced motion shows final values and18 * 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 turns60 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 <span66 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 line105 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 Cargora252 </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 numbers257 </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`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.