liten

Metric Card

A hero metric that counts up on mount above a hoverable chart with a gliding crosshair readout.

Portfolio Value

$22,770.46+16.2%vs last month

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.

Terminal
bun add motion

Copy the component into components/ui/metric-card.tsx.

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

Example.tsx
import { MetricCard } from '@/components/ui/metric-card';

export default function Example() {
  return <MetricCard />;
}

Examples

Pass your own accent, series, and footer stats.

Monthly Revenue

$44,353+9.4%vs last month

Top Channel

OrganicSearch & referral

+24.0%

Growth (YTD)

A negative change reads correctly, with the trend icon and color flipping.

Active Subscriptions

1,181-3.6%vs last week

Top Plan

ProAnnual billing

-8.1%

Churn (QTD)

Props

PropTypeDefaultDescription
titlestring"Portfolio Value"Header label above the value.
valuenumber24750.5The hero number; it counts up to this on first paint.
prefixstring"$"Currency / unit prefix on the value.
decimalsnumber2Decimal places shown on the value.
changenumber16.2Period-over-period change shown in the badge.
changeLabelstring"vs last month"Muted text after the change pill.
datanumber[]-Series driving the chart, oldest to newest.
accentstring"#3ecf8e"Accent that drives the chart, badge, focus outline.
performerMetricPerformer-Optional footer highlight (left side).
stat{ value: string; label: string }-Optional footer stat (right side), e.g. YTD return.
classNamestring-Forwarded to the root.
On this page0%