A dashboard KPI card whose headline number counts up with an ease-out curve while a gradient-filled sparkline draws itself in and an end-point dot pops on. A colored delta badge shows the change versus the previous period, with an option to treat decreases as good.
Preview
$48,294
+12.4% vs last month
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add stat-cardWrites components/ui/stat-card.tsx and components/styles/stat-card.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { StatCard } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <StatCard />6 );7}Props
Accessibility
The card is a <section> labelled by its title. The counting number is aria-hidden and the final formatted value is provided as visually hidden text, so screen readers never hear intermediate values. The delta badge includes visually hidden 'Up'/'Down' text (color is not the only cue; the arrow icon also changes). The sparkline is role="img" with an aria-label summarising the range. Under prefers-reduced-motion the value appears immediately and the sparkline is drawn statically.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/stat-card.module.css";5 6export interface StatCardProps {7 /** Metric name shown at the top of the card */8 title?: string;9 /** Final value the number counts up to */10 value?: number;11 /** Text before the number (e.g. "$") */12 prefix?: string;13 /** Text after the number (e.g. "%", "ms") */14 suffix?: string;15 /** Decimal places shown */16 decimals?: number;17 /** Change versus the previous period, in percent (negative = down) */18 delta?: number;19 /** Text after the delta badge */20 deltaLabel?: string;21 /** Treat a decrease as good (e.g. churn, latency) — flips the delta colors */22 invertDelta?: boolean;23 /** Sparkline data points (oldest first) */24 data?: number[];25 /** Show the sparkline */26 showSparkline?: boolean;27 /** Sparkline / accent color */28 accentColor?: string;29 /** Count-up and draw-in duration in milliseconds (0 = instant) */30 duration?: number;31 /** Optional className merged onto the root */32 className?: string;33 /** Optional inline styles merged onto the root */34 style?: React.CSSProperties;35}36 37const DEFAULT_DATA = [32, 38, 35, 44, 41, 49, 47, 55, 52, 61, 58, 68];38const W = 280;39const H = 64;40 41function format(n: number, decimals: number) {42 return n.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });43}44 45export function StatCard({46 title = "Monthly revenue",47 value = 48294,48 prefix = "$",49 suffix = "",50 decimals = 0,51 delta = 12.4,52 deltaLabel = "vs last month",53 invertDelta = false,54 data = DEFAULT_DATA,55 showSparkline = true,56 accentColor = "#6366f1",57 duration = 1400,58 className = "",59 style,60}: StatCardProps) {61 const id = React.useId().replace(/:/g, "");62 const dec = Math.max(0, Math.min(4, Math.floor(decimals)));63 const [shown, setShown] = React.useState(0);64 const [reduce, setReduce] = React.useState(false);65 66 React.useEffect(() => {67 const r = window.matchMedia("(prefers-reduced-motion: reduce)").matches;68 setReduce(r);69 if (r || duration <= 0) {70 setShown(value);71 return;72 }73 let raf = 0;74 let start = 0;75 const tick = (now: number) => {76 if (!start) start = now;77 const t = Math.min(1, (now - start) / duration);78 setShown(value * (1 - Math.pow(1 - t, 4)));79 if (t < 1) raf = requestAnimationFrame(tick);80 };81 raf = requestAnimationFrame(tick);82 return () => cancelAnimationFrame(raf);83 }, [value, duration]);84 85 const up = delta >= 0;86 const good = invertDelta ? !up : up;87 const pts = data.length > 1 ? data : [0, 0];88 const min = Math.min(...pts);89 const max = Math.max(...pts);90 const span = max - min || 1;91 const coords = pts.map((v, i) => [8 + (i / (pts.length - 1)) * (W - 16), H - 6 - ((v - min) / span) * (H - 12)] as const);92 const line = coords.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");93 const area = `${line} L${W - 8} ${H} L8 ${H} Z`;94 const last = coords[coords.length - 1] ?? [W, H / 2];95 const final = `${prefix}${format(value, dec)}${suffix}`;96 97 return (98 <section99 aria-label={title}100 className={`${styles.root} ${className}`.trim()}101 data-animate={!reduce && duration > 0 ? "" : undefined}102 style={{ "--mf-stc-accent": accentColor, "--mf-stc-dur": `${Math.max(0, duration)}ms`, ...style } as React.CSSProperties}103 >104 <div className={styles.head}>105 <span className={styles.title}>{title}</span>106 <span className={styles.badge} data-good={good ? "" : undefined}>107 <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" strokeLinejoin="round">108 {up ? <path d="M7 17 17 7M9 7h8v8" /> : <path d="M7 7l10 10M17 9v8H9" />}109 </svg>110 <span className={styles.srOnly}>{up ? "Up" : "Down"}</span>111 {Math.abs(delta).toFixed(1)}%112 </span>113 </div>114 <p className={styles.value}>115 <span className={styles.srOnly}>{final}</span>116 <span aria-hidden="true">117 {prefix}118 {format(shown, dec)}119 {suffix}120 </span>121 </p>122 {deltaLabel && <p className={styles.sub}>{`${up ? "+" : "−"}${Math.abs(delta).toFixed(1)}% ${deltaLabel}`}</p>}123 {showSparkline && (124 <div className={styles.sparkWrap}>125 <svg126 role="img"127 aria-label={`Trend over ${pts.length} periods, from ${format(pts[0] ?? 0, 0)} to ${format(pts[pts.length - 1] ?? 0, 0)}`}128 className={styles.spark}129 viewBox={`0 0 ${W} ${H}`}130 >131 <defs>132 <linearGradient id={`${id}-fill`} x1="0" y1="0" x2="0" y2="1">133 <stop offset="0%" stopColor={accentColor} stopOpacity="0.32" />134 <stop offset="100%" stopColor={accentColor} stopOpacity="0" />135 </linearGradient>136 </defs>137 <path className={styles.area} d={area} fill={`url(#${id}-fill)`} />138 <path className={styles.line} d={line} pathLength={1} fill="none" stroke={accentColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />139 </svg>140 <span141 aria-hidden="true"142 className={styles.endDot}143 style={{ left: `${(last[0] / W) * 100}%`, top: `${(last[1] / H) * 100}%` } as React.CSSProperties}144 />145 </div>146 )}147 </section>148 );149}