Metric Card
A hero metric that counts up on mount above a hoverable chart with a gliding crosshair readout.
Portfolio Value
Top Performer
VTIVanguard Total Stock Market ETF
+18.2%
Return (YTD)
The hero value counts up from a recent floor on first paint, and the chart line draws itself left to right on a synced wipe. Hovering snaps a crosshair to the nearest real data point and a floating readout glides between values instead of re-popping on each move. The series is deterministic, so the chart renders identically on server and client. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/metric-card.tsx.
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { Star, TrendingDown, TrendingUp } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;export type MetricPerformer = { ticker: string; name: string; label?: string;};export type MetricCardProps = { title?: string; value?: number; prefix?: string; decimals?: number; change?: number; changeLabel?: string; data?: number[]; accent?: string; performer?: MetricPerformer; stat?: { value: string; label: string }; className?: string;};const SHAPE = [ 0.16, 0.18, 0.22, 0.28, 0.33, 0.36, 0.34, 0.31, 0.4, 0.56, 0.66, 0.61, 0.5, 0.45, 0.47, 0.58, 0.76, 0.9, 1,];function generate(value: number): number[] { const min = value * 0.62; return SHAPE.map((rel) => Math.round((min + rel * (value - min)) * 100) / 100);}function linePath(pts: { x: number; y: number }[]): string { if (pts.length < 2) return ''; let d = `M ${pts[0].x},${pts[0].y}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] ?? pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] ?? p2; const c1x = p1.x + (p2.x - p0.x) / 6; const c1y = p1.y + (p2.y - p0.y) / 6; const c2x = p2.x - (p3.x - p1.x) / 6; const c2y = p2.y - (p3.y - p1.y) / 6; d += ` C ${c1x},${c1y} ${c2x},${c2y} ${p2.x},${p2.y}`; } return d;}const PAD_TOP = 26;const CHART_H = 152;export function MetricCard({ title = 'Portfolio Value', value = 24750.5, prefix = '$', decimals = 2, change = 16.2, changeLabel = 'vs last month', data, accent = '#3ecf8e', performer = { ticker: 'VTI', name: 'Vanguard Total Stock Market ETF', label: 'Top Performer', }, stat = { value: '+18.2%', label: 'Return (YTD)' }, className,}: MetricCardProps) { const reduce = useReducedMotion(); const wrapRef = React.useRef<HTMLDivElement>(null); const [w, setW] = React.useState(440); const series = React.useMemo(() => data ?? generate(value), [data, value]); React.useLayoutEffect(() => { const el = wrapRef.current; if (!el) return; const measure = () => setW(el.clientWidth); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); const { points, line, area } = React.useMemo(() => { const min = Math.min(...series); const max = Math.max(...series); const span = max - min || 1; const usableH = CHART_H - PAD_TOP; const pts = series.map((v, i) => ({ x: (i / (series.length - 1)) * w, y: PAD_TOP + (1 - (v - min) / span) * usableH, v, })); const ln = linePath(pts); const ar = `${ln} L ${w},${CHART_H} L 0,${CHART_H} Z`; return { points: pts, line: ln, area: ar }; }, [series, w]); const [shown, setShown] = React.useState(() => (reduce ? value : value * 0.92)); React.useEffect(() => { if (reduce) { setShown(value); return; } const from = value * 0.92; const duration = 900; let raf = 0; let start = 0; const tick = (ts: number) => { if (!start) start = ts; const p = Math.min((ts - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); setShown(from + (value - from) * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [value, reduce]); const fmt = (n: number) => `${prefix}${n.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, })}`; const up = change >= 0; const Trend = up ? TrendingUp : TrendingDown; const [hover, setHover] = React.useState<number | null>(null); const active = hover != null ? points[hover] : null; const last = points[points.length - 1]; const onMove = (e: React.PointerEvent<SVGRectElement>) => { const box = e.currentTarget.getBoundingClientRect(); const x = e.clientX - box.left; const idx = Math.max( 0, Math.min(points.length - 1, Math.round((x / box.width) * (points.length - 1))), ); setHover(idx); }; const gid = React.useId().replace(/[:]/g, ''); return ( <div style={{ '--metric-accent': accent } as React.CSSProperties} className={cn( 'bloom-edge w-[440px] max-w-full overflow-hidden rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]', className, )} > <div className="px-5 pt-5"> <h2 className="text-[13px] font-medium tracking-[-0.01em] text-neutral-500 dark:text-[#929292]"> {title} </h2> <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2"> <span className="text-[34px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-neutral-900 dark:text-white"> {fmt(shown)} </span> {change != null && ( <span className="flex items-center gap-2"> <span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[12px] font-medium tabular-nums" style={{ color: 'var(--metric-accent)', backgroundColor: 'color-mix(in srgb, var(--metric-accent) 14%, transparent)', }} > <Trend size={13} strokeWidth={2.25} aria-hidden /> {up ? '+' : ''} {change}% </span> <span className="text-[12px] text-neutral-500 dark:text-[#929292]"> {changeLabel} </span> </span> )} </div> </div> <div ref={wrapRef} className="relative mt-4" style={{ height: CHART_H }} onPointerLeave={() => setHover(null)} > <svg width={w} height={CHART_H} viewBox={`0 0 ${w} ${CHART_H}`} className="block" aria-hidden > <defs> <linearGradient id={`fill-${gid}`} x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="var(--metric-accent)" stopOpacity="0.4" /> <stop offset="100%" stopColor="var(--metric-accent)" stopOpacity="0" /> </linearGradient> <clipPath id={`wipe-${gid}`}> <motion.rect x="0" y="0" height={CHART_H} initial={{ width: reduce ? w : 0 }} animate={{ width: w }} transition={{ duration: reduce ? 0 : 1.1, ease: EASE_OUT }} /> </clipPath> </defs> <g clipPath={`url(#wipe-${gid})`}> <path d={area} fill={`url(#fill-${gid})`} /> <motion.path d={line} fill="none" stroke="var(--metric-accent)" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : 1.1, ease: EASE_OUT }} /> </g> {last && ( <motion.circle cx={last.x} cy={last.y} r={3.5} fill="var(--metric-accent)" initial={{ opacity: reduce ? 1 : 0 }} animate={{ opacity: active ? 0 : 1 }} transition={{ duration: 0.25, ease: EASE_OUT, delay: reduce ? 0 : 1 }} style={{ filter: 'drop-shadow(0 0 0 2px color-mix(in srgb, var(--metric-accent) 22%, transparent))', }} /> )} <AnimatePresence> {active && ( <motion.g initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.14, ease: EASE_OUT }} > <motion.line x1={active.x} x2={active.x} y1={PAD_TOP - 8} y2={CHART_H} stroke="var(--metric-accent)" strokeWidth={1} strokeOpacity={0.35} strokeDasharray="3 3" initial={false} animate={{ x1: active.x, x2: active.x }} transition={{ duration: 0.18, ease: EASE_IN_OUT }} /> <motion.circle r={4.5} fill="var(--metric-accent)" initial={false} animate={{ cx: active.x, cy: active.y }} transition={{ duration: 0.18, ease: EASE_IN_OUT }} style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35)) drop-shadow(0 0 0 3px color-mix(in srgb, var(--metric-accent) 20%, transparent))', }} /> </motion.g> )} </AnimatePresence> <rect x="0" y="0" width={w} height={CHART_H} fill="transparent" onPointerMove={onMove} style={{ cursor: 'crosshair' }} /> </svg> <AnimatePresence> {active && ( <motion.div className="pointer-events-none absolute top-0 left-0 z-10" style={{ x: '-50%', y: '-100%' }} initial={{ opacity: 0 }} animate={{ opacity: 1, left: Math.max(44, Math.min(w - 44, active.x)), top: Math.max(8, active.y - 10), }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.18, ease: EASE_IN_OUT }} > <div className={cn( 'bloom-edge -translate-y-1 whitespace-nowrap rounded-lg px-2 py-1 text-[12px] font-semibold tabular-nums tracking-[-0.01em]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#272727] dark:to-[#191919] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_6px_16px_-6px_rgba(0,0,0,0.2)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_6px_16px_-10px_rgba(0,0,0,0.45)]', )} > {fmt(active.v)} </div> </motion.div> )} </AnimatePresence> </div> {(performer || stat) && ( <div className="flex items-center gap-4 border-t border-black/[0.06] px-5 py-4 dark:border-white/[0.06]"> {performer && ( <div className="flex min-w-0 items-center gap-3"> <span className={cn( 'bloom-edge grid size-9 shrink-0 place-items-center rounded-[10px]', 'bg-black/[0.03] text-neutral-700 dark:bg-black/20 dark:text-neutral-200', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} > <Star size={16} strokeWidth={1.75} fill="currentColor" aria-hidden /> </span> <div className="min-w-0"> <p className="text-[11px] text-neutral-500 dark:text-[#929292]"> {performer.label ?? 'Top Performer'} </p> <p className="flex items-baseline gap-1.5"> <span className="text-[15px] font-semibold tracking-[-0.01em] text-neutral-900 dark:text-white"> {performer.ticker} </span> <span className="truncate text-[11px] text-neutral-500 dark:text-[#929292]"> {performer.name} </span> </p> </div> </div> )} {stat && ( <div className="ml-auto shrink-0 text-right"> <p className="text-[15px] font-semibold tracking-[-0.01em] tabular-nums" style={{ color: 'var(--metric-accent)' }} > {stat.value} </p> <p className="text-[11px] text-neutral-500 dark:text-[#929292]"> {stat.label} </p> </div> )} </div> )} </div> );}Usage
import { MetricCard } from '@/components/ui/metric-card';
export default function Example() {
return <MetricCard />;
}Examples
Pass your own accent, series, and footer stats.
Monthly Revenue
Top Channel
OrganicSearch & referral
+24.0%
Growth (YTD)
A negative change reads correctly, with the trend icon and color flipping.
Active Subscriptions
Top Plan
ProAnnual billing
-8.1%
Churn (QTD)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Portfolio Value" | Header label above the value. |
value | number | 24750.5 | The hero number; it counts up to this on first paint. |
prefix | string | "$" | Currency / unit prefix on the value. |
decimals | number | 2 | Decimal places shown on the value. |
change | number | 16.2 | Period-over-period change shown in the badge. |
changeLabel | string | "vs last month" | Muted text after the change pill. |
data | number[] | - | Series driving the chart, oldest to newest. |
accent | string | "#3ecf8e" | Accent that drives the chart, badge, focus outline. |
performer | MetricPerformer | - | Optional footer highlight (left side). |
stat | { value: string; label: string } | - | Optional footer stat (right side), e.g. YTD return. |
className | string | - | Forwarded to the root. |