A headline number that counts up beside a sparkline that draws itself, with a change badge and hover readout.
Monthly revenue
15.0% up on last period$48,200
Trend over 12 periods, from $29,884 to $48,200.
Customize
Usage
import { TrendingUp } from "lucide-react";
import { KpiCard } from "@/components/motionkit/kpi-card";
<KpiCard
label="Monthly revenue"
value={48200}
previous={41900}
data={[29884, 27956, 31812, 30848, 34222, 33258, 37596, 35668, 40006, 42416, 41452, 48200]}
prefix="$"
icon={<TrendingUp />}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Count and line draw
Scrolls into view
End dot
Line nearly drawn
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/kpi-card.tsx.
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type KpiCardProps = {
label: string;
value: number;
/** Last period's value. The change badge is worked out from it. */
previous?: number;
/** Sparkline points, oldest first. The last point should match `value`. */
data: number[];
prefix?: string;
suffix?: string;
decimals?: number;
/** BCP 47 locale used for thousands separators. */
locale?: string;
/** Seconds for the count and the line draw. */
duration?: number;
/** Line colour. */
color?: string;
/** Treat a fall as good news, e.g. for churn or response time. */
invert?: boolean;
icon?: ReactNode;
className?: string;
};
const ease = [0.16, 1, 0.3, 1] as const;
const CHART_HEIGHT = 56;
// room above and below so the line and its end dot never touch the edge
const PAD = 6;
function toPoints(data: number[], width: number) {
const min = Math.min(...data);
const range = Math.max(...data) - min || 1;
return data.map((value, i) => ({
x: data.length > 1 ? (i / (data.length - 1)) * width : width / 2,
y: PAD + (1 - (value - min) / range) * (CHART_HEIGHT - PAD * 2),
}));
}
/** A smooth curve through every point (Catmull-Rom, drawn as cubic Béziers). */
function smoothPath(points: { x: number; y: number }[]) {
if (points.length < 2) return "";
let d = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] ?? points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] ?? p2;
d += ` C ${p1.x + (p2.x - p0.x) / 6} ${p1.y + (p2.y - p0.y) / 6}, ${p2.x - (p3.x - p1.x) / 6} ${p2.y - (p3.y - p1.y) / 6}, ${p2.x} ${p2.y}`;
}
return d;
}
export function KpiCard({
label,
value,
previous,
data,
prefix = "",
suffix = "",
decimals = 0,
locale = "en-US",
duration = 1.4,
color = "#5FAEB4",
invert = false,
icon,
className = "",
}: KpiCardProps) {
const ref = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduceMotion = useReducedMotion();
const gradientId = useId();
const [width, setWidth] = useState(0);
const [hover, setHover] = useState<number | null>(null);
const format = (number: number) =>
`${prefix}${number.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix}`;
const count = useMotionValue(0);
const shown = useTransform(count, format);
useEffect(() => {
if (!inView) return;
if (reduceMotion) {
count.set(value);
return;
}
const controls = animate(count, value, { duration, ease });
return () => controls.stop();
}, [inView, value, duration, reduceMotion, count]);
// the chart is drawn in real pixels: a stretched viewBox would distort the stroke and break the line draw
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(chart);
return () => observer.disconnect();
}, []);
const points = width ? toPoints(data, width) : [];
const line = smoothPath(points);
const area = line && `${line} L ${width} ${CHART_HEIGHT} L 0 ${CHART_HEIGHT} Z`;
const end = points[points.length - 1];
const marker = hover === null ? undefined : points[hover];
const drawn = inView || !!reduceMotion;
const change = previous ? ((value - previous) / Math.abs(previous)) * 100 : null;
const good = change !== null && (invert ? change <= 0 : change >= 0);
function handleMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect();
const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
setHover(Math.round(ratio * (data.length - 1)));
}
return (
<div ref={ref} className={`rounded-2xl border border-white/10 bg-[#141B1B] p-5 text-[#F6F1E8] ${className}`}>
<div className="flex items-start justify-between gap-3">
<p className="flex items-center gap-1.5 text-xs text-[#94A9A7] [&>svg]:size-3.5 [&>svg]:shrink-0">
{icon}
{label}
</p>
{change !== null && (
<span
className={`shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums ${
good ? "bg-[#7D9E9D]/15 text-[#A9C4C3]" : "bg-[#A56D5B]/15 text-[#D39A86]"
}`}
>
<span aria-hidden>{change >= 0 ? "▲" : "▼"} </span>
{Math.abs(change).toFixed(1)}%
<span className="sr-only"> {change >= 0 ? "up" : "down"} on last period</span>
</span>
)}
</div>
<p className="mt-2 text-3xl font-semibold tracking-tight tabular-nums">
{/* screen readers hear the final value, not every frame of the count */}
<motion.span aria-hidden>{shown}</motion.span>
<span className="sr-only">{format(value)}</span>
</p>
<div
ref={chartRef}
aria-hidden
onPointerMove={handleMove}
onPointerLeave={() => setHover(null)}
className="relative mt-4 touch-pan-y"
style={{ height: CHART_HEIGHT }}
>
{width > 0 && (
<svg width={width} height={CHART_HEIGHT} viewBox={`0 0 ${width} ${CHART_HEIGHT}`} className="absolute inset-0 overflow-visible">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.28} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<motion.path
d={area}
fill={`url(#${gradientId})`}
initial={{ opacity: reduceMotion ? 1 : 0 }}
animate={{ opacity: drawn ? 1 : 0 }}
transition={{ duration: duration * 0.6, delay: reduceMotion ? 0 : duration * 0.5 }}
/>
<motion.path
d={line}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: drawn ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : duration, ease }}
/>
{end && (
<motion.circle
cx={end.x}
cy={end.y}
r={4}
fill={color}
stroke="#141B1B"
strokeWidth={3}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
initial={{ scale: reduceMotion ? 1 : 0 }}
animate={{ scale: drawn ? 1 : 0 }}
transition={{ type: "spring", bounce: 0.5, duration: 0.5, delay: reduceMotion ? 0 : duration * 0.85 }}
/>
)}
{marker && (
<>
<line x1={marker.x} x2={marker.x} y1={0} y2={CHART_HEIGHT} stroke="white" strokeOpacity={0.15} />
<circle cx={marker.x} cy={marker.y} r={4} fill={color} stroke="#141B1B" strokeWidth={2} />
</>
)}
</svg>
)}
{/* beside the point rather than above it, so it never covers the headline number;
it switches sides past the middle to stay inside the card */}
{marker && hover !== null && (
<span
className={`pointer-events-none absolute -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#0C1010] px-1.5 py-0.5 text-[11px] tabular-nums text-[#F6F1E8] ${
marker.x > width * 0.55 ? "-translate-x-[calc(100%+10px)]" : "translate-x-2.5"
}`}
style={{ left: marker.x, top: marker.y }}
>
{format(data[hover])}
</span>
)}
</div>
<p className="sr-only">
Trend over {data.length} periods, from {format(data[0] ?? 0)} to {format(data[data.length - 1] ?? 0)}.
</p>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | What the number measures. |
| value | number | — | The headline figure. |
| data | number[] | — | Sparkline points, oldest first. |
| previous | number | — | Last period's value. Adds a change badge. |
| invert | boolean | false | Treat a fall as good news, e.g. churn. |
| prefix / suffix | string | "" | Text before and after the number. |
| decimals | number | 0 | Fixed decimal places. |
| duration | number | 1.4 | Seconds for the count and the line draw. |
| color | string | "#5FAEB4" | Line colour. |
| icon | ReactNode | — | Optional icon beside the label. |
Odometer
Every digit rolls on its own wheel to the new value, like a mileage counter, rather than counting up.
Monthly revenue
Number Ticker
Counts up to a value with an eased finish when it scrolls into view.
Small details make the whole thing feel alive.
Word Rise
Words lift out of a blur one after another as the line scrolls into view.