UIData Displayno dependencies

StatCard

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

Monthly revenueUp12.4%

$48,294

+12.4% vs last month

Customize

11 props
48294
0
12.4
1400

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add stat-card

Writes 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.

example.tsx
1import { StatCard } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <StatCard />6  );7}

Props

PropTypeDefault
title

Metric name; also the card's accessible name.

string'Monthly revenue'
value

Final value the number counts up to.

number48294
prefix

Text before the number.

string'$'
suffix

Text after the number (e.g. '%', 'ms').

string''
decimals

Decimal places shown (0-4).

number0
delta

Percent change versus the previous period; negative values point down.

number12.4
deltaLabel

Comparison text under the value (empty hides the line).

string'vs last month'
invertDelta

Treat a decrease as good (green) — for churn, latency, costs.

booleanfalse
data

Sparkline points, oldest first.

number[][32, 38, 35, 44, 41, 49, 47, 55, 52, 61, 58, 68]
showSparkline

Show the animated sparkline.

booleantrue
accentColor

Sparkline stroke, fill and card glow color.

string'#6366f1'
duration

Count-up and draw-in duration in milliseconds (0 = instant).

number1400
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/stat-card.tsx
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}