# Stat cards

> Four metric cards — headline KPI, scrubbable sparkline, goal ring and two-period comparison — with count-ups, redrawn lines, springing arcs and bars.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/stat-cards
- Install: `npx shadcn@latest add @geturui/stat-cards`
- Dependencies: motion, lucide-react

## Variants

### KPI

Headline figure that counts up with its delta, and a breakdown that springs open into a stacked bar.

Install: `npx shadcn@latest add @geturui/stat-cards-kpi`

```tsx
import { KpiCard } from "@/components/examples/stat-cards/kpi";

export function Mrr() {
  return <KpiCard label="MRR" value={84250} previous={74960} segments={[{ label: "Subscriptions", value: 61400 }]} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| label / period | `string` | — | Metric name and period caption. |
| value / previous | `number` | 84250 / 74960 | Current and previous values. |
| previousLabel | `string` | "Sep" | Name of the previous period. |
| format | `"currency" | "number"` | "currency" | Number formatting. |
| segments | `{ label: string; value: number }[]` | — | Breakdown parts. |
| invert | `boolean` | false | Treat a drop as good. |
| className | `string` | — | Extra classes on the card. |

### Sparkline

Period tabs redraw the line; pointer or arrow keys scrub to any point and the figure crossfades.

Install: `npx shadcn@latest add @geturui/stat-cards-sparkline`

```tsx
import { SparklineStatCard } from "@/components/examples/stat-cards/sparkline";

export function Users() {
  return <SparklineStatCard label="Active users" series={[{ key: "7D", caption: "past 7 days", points, labels }]} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| label | `string` | "Active users" | Metric name. |
| unit | `string` | "" | Suffix after the number. |
| series | `SparkSeries[]` | — | { key, caption, points, labels } per tab. |
| defaultKey | `string` | — | Initially selected tab. |
| className | `string` | — | Extra classes on the card. |

### Goal ring

Progress ring and percentage spring to each logged step; milestones tick and the goal swaps to a done state.

Install: `npx shadcn@latest add @geturui/stat-cards-goal`

```tsx
import { GoalRingCard } from "@/components/examples/stat-cards/goal";

export function Quota() {
  return <GoalRingCard label="Q4 sales goal" defaultValue={34} goal={48} onChange={(v) => save(v)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| label / unit / deadline | `string` | — | Captions. |
| defaultValue / goal | `number` | 34 / 48 | Starting progress and target. |
| milestones | `number[]` | [0.25, 0.5, 0.75, 1] | Checkpoints as fractions of the goal. |
| onChange | `(value: number) => void` | — | Called after each step. |
| className | `string` | — | Extra classes on the card. |

### Comparison

Metric tabs swap two period bars that spring to new lengths while the delta crossfades.

Install: `npx shadcn@latest add @geturui/stat-cards-comparison`

```tsx
import { ComparisonStatCard } from "@/components/examples/stat-cards/comparison";

export function MonthOverMonth() {
  return <ComparisonStatCard metrics={[{ key: "revenue", label: "Revenue", current: 48210, previous: 39870, prefix: "$" }]} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title / currentLabel / previousLabel | `string` | — | Captions. |
| metrics | `ComparisonMetric[]` | — | { key, label, current, previous, prefix?, suffix?, invert? }. |
| defaultKey | `string` | — | Initially selected metric. |
| className | `string` | — | Extra classes on the card. |

## Accessibility

- Animated numbers render a static value for screen readers; deltas state Up or Down in words alongside the colour.
- The sparkline is a slider: arrow keys move through points and the value text is announced.
- Progress uses role=progressbar with value text, and reduced motion jumps straight to final values.

## Source

### components/examples/stat-cards/kpi.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { ArrowDownRight, ArrowUpRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

export interface KpiSegment {
  label: string;
  value: number;
}

export interface KpiCardProps {
  label?: string;
  /** Period caption, e.g. "October 2026". */
  period?: string;
  value?: number;
  /** Value for the previous period, used for the delta. */
  previous?: number;
  /** Label for the previous period, e.g. "Sep". */
  previousLabel?: string;
  format?: "currency" | "number";
  currency?: string;
  /** Parts that add up to `value`, shown in the breakdown. */
  segments?: KpiSegment[];
  /** When true a drop is the good direction (e.g. churn). */
  invert?: boolean;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";
const shades = ["bg-ink", "bg-ink/55", "bg-ink/25"];

/** Headline KPI that counts up on mount, shows its delta with direction and word, and springs open a stacked breakdown. */
export function KpiCard({
  label = "Net revenue",
  period = "October 2026",
  value = 84250,
  previous = 74960,
  previousLabel = "Sep",
  format = "currency",
  currency = "USD",
  segments = [
    { label: "Subscriptions", value: 61400 },
    { label: "Add-ons", value: 15900 },
    { label: "Services", value: 6950 },
  ],
  invert = false,
  className,
}: KpiCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [open, setOpen] = React.useState(false);
  const panelId = React.useId();

  const fmt = React.useMemo(
    () => (format === "currency" ? new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }) : new Intl.NumberFormat("en-US")),
    [format, currency],
  );
  const mv = useSpring(reduce ? value : 0, { stiffness: 55, damping: 18 });
  const text = useTransform(mv, (v) => fmt.format(Math.round(v)));
  React.useEffect(() => {
    if (reduce) mv.jump(value);
    else mv.set(value);
  }, [value, reduce, mv]);

  const change = previous ? (value - previous) / previous : 0;
  const up = change >= 0;
  const good = invert ? !up : up;
  const total = segments.reduce((a, s) => a + s.value, 0) || 1;

  return (
    <section aria-label={label} className={cn("w-full max-w-[360px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <div className="flex items-center justify-between gap-3">
        <p className="text-[13px] font-medium text-ink-muted">{label}</p>
        <p className="font-mono text-[11px] text-ink-subtle">{period}</p>
      </div>
      <p className="mt-2 font-display text-[38px] font-semibold leading-none tracking-[-1.2px] text-ink tabular-nums">
        <motion.span aria-hidden>{text}</motion.span>
        <span className="sr-only">{fmt.format(value)}</span>
      </p>
      <div className="mt-3 flex flex-wrap items-center gap-2">
        <motion.span
          initial={reduce ? false : { opacity: 0, y: 6 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ type: "spring", stiffness: 300, damping: 24, delay: reduce ? 0 : 0.5 }}
          className={cn("inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold", good ? "bg-success/12 text-success" : "bg-danger/12 text-danger")}
        >
          {up ? <ArrowUpRight aria-hidden className="size-3.5" /> : <ArrowDownRight aria-hidden className="size-3.5" />}
          <span className="sr-only">{up ? "Up" : "Down"}</span>
          {Math.abs(change * 100).toFixed(1)}%
        </motion.span>
        <span className="text-xs text-ink-subtle">
          vs {fmt.format(previous)} in {previousLabel}
        </span>
      </div>

      <div className="mt-5 border-t border-border pt-3">
        <button
          type="button"
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen((o) => !o)}
          className={cn("flex w-full items-center justify-between rounded-xs py-1 text-[13px] font-medium text-ink", focus)}
        >
          {open ? "Hide breakdown" : "Show breakdown"}
          <ChevronDown aria-hidden className={cn("size-4 text-ink-muted transition-transform duration-300 ease-spring", open && "rotate-180")} />
        </button>
        <AnimatePresence initial={false}>
          {open && (
            <motion.div
              id={panelId}
              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
              animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
              exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
              transition={{ type: "spring", stiffness: 320, damping: 32 }}
              className="overflow-hidden"
            >
              <div className="mt-3 flex h-2.5 gap-0.5 overflow-hidden rounded-full">
                {segments.map((s, i) => (
                  <motion.span
                    key={s.label}
                    className={cn("h-full origin-left first:rounded-l-full last:rounded-r-full", shades[i % shades.length])}
                    style={{ width: `${(s.value / total) * 100}%` }}
                    initial={reduce ? false : { scaleX: 0 }}
                    animate={{ scaleX: 1 }}
                    transition={{ type: "spring", stiffness: 160, damping: 22, delay: reduce ? 0 : 0.08 + i * 0.08 }}
                  />
                ))}
              </div>
              <ul className="mt-3 space-y-1.5 pb-1">
                {segments.map((s, i) => (
                  <li key={s.label} className="flex items-center gap-2 text-[13px]">
                    <span aria-hidden className={cn("size-2 rounded-full", shades[i % shades.length])} />
                    <span className="flex-1 text-ink-muted">{s.label}</span>
                    <span className="font-medium tabular-nums text-ink">{fmt.format(s.value)}</span>
                    <span className="w-10 text-right font-mono text-[11px] text-ink-subtle">{Math.round((s.value / total) * 100)}%</span>
                  </li>
                ))}
              </ul>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

```

### components/examples/stat-cards/sparkline.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SparkSeries {
  /** Tab label, e.g. "7D". */
  key: string;
  /** Word used in the caption, e.g. "past 7 days". */
  caption: string;
  points: number[];
  /** Label per point, same length as points. */
  labels: string[];
}

export interface SparklineStatCardProps {
  label?: string;
  unit?: string;
  series?: SparkSeries[];
  defaultKey?: string;
  className?: string;
}

const DAYS = ["Sep 26", "Sep 27", "Sep 28", "Sep 29", "Sep 30", "Oct 1", "Oct 2"];
const WEEKS = ["Aug 7", "Aug 14", "Aug 21", "Aug 28", "Sep 4", "Sep 11", "Sep 18", "Sep 25", "Oct 2"];
const MONTHS = ["Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"];

const DEFAULT_SERIES: SparkSeries[] = [
  { key: "7D", caption: "past 7 days", points: [1840, 2120, 1980, 2410, 2290, 2630, 2754], labels: DAYS },
  { key: "60D", caption: "past 60 days", points: [12400, 13150, 12890, 14020, 13700, 14980, 15420, 15110, 16240], labels: WEEKS },
  { key: "1Y", caption: "past year", points: [38e3, 41e3, 39.5e3, 44e3, 47e3, 46e3, 51e3, 55e3, 53e3, 58e3, 61e3, 64.8e3], labels: MONTHS },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";
const W = 300;
const H = 72;

function coords(points: number[]) {
  const min = Math.min(...points);
  const span = Math.max(...points) - min || 1;
  return points.map((p, i) => ({ x: (i / (points.length - 1)) * W, y: H - 6 - ((p - min) / span) * (H - 14) }));
}

/** Stat with a scrubbable sparkline: period tabs redraw the line, and pointer or arrow keys read out any point. */
export function SparklineStatCard({ label = "Active users", unit = "", series = DEFAULT_SERIES, defaultKey, className }: SparklineStatCardProps) {
  const reduce = useReducedMotion() ?? false;
  const baseId = React.useId();
  const [key, setKey] = React.useState(defaultKey ?? series[0]?.key);
  const [hover, setHover] = React.useState<number | null>(null);
  const s = series.find((x) => x.key === key) ?? series[0];
  const pts = coords(s.points);
  const d = pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
  const idx = hover ?? s.points.length - 1;
  const value = s.points[idx];
  const change = (s.points[s.points.length - 1] - s.points[0]) / s.points[0];
  const up = change >= 0;
  const fmt = (n: number) => n.toLocaleString("en-US") + unit;

  function onMove(e: React.PointerEvent<SVGSVGElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    const t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
    setHover(Math.round(t * (s.points.length - 1)));
  }

  function onKey(e: React.KeyboardEvent) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    const d2 = e.key === "ArrowRight" ? 1 : -1;
    setHover(Math.min(s.points.length - 1, Math.max(0, idx + d2)));
  }

  return (
    <section aria-label={label} className={cn("w-full max-w-[360px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0">
          <p className="text-[13px] font-medium text-ink-muted">{label}</p>
          <div className="relative mt-1.5 h-9">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.p
                key={`${s.key}-${idx}`}
                initial={{ opacity: 0, filter: "blur(6px)", y: 4 }}
                animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
                exit={{ opacity: 0, filter: "blur(6px)", y: -4 }}
                transition={{ duration: 0.18 }}
                className="font-display text-[32px] font-semibold leading-9 tracking-[-1px] text-ink tabular-nums"
              >
                {fmt(value)}
              </motion.p>
            </AnimatePresence>
          </div>
          <p className="mt-1 text-xs text-ink-subtle" aria-live="polite">
            {hover === null ? (
              <span className={cn("inline-flex items-center gap-1 font-medium", up ? "text-success" : "text-danger")}>
                {up ? <TrendingUp aria-hidden className="size-3.5" /> : <TrendingDown aria-hidden className="size-3.5" />}
                {up ? "Up" : "Down"} {Math.abs(change * 100).toFixed(1)}%<span className="font-normal text-ink-subtle">{s.caption}</span>
              </span>
            ) : (
              <span>{s.labels[idx]}</span>
            )}
          </p>
        </div>
        <div role="group" aria-label="Period" className="flex shrink-0 gap-0.5 rounded-full bg-surface-sunken p-0.5">
          {series.map((x) => {
            const on = x.key === s.key;
            return (
              <button
                key={x.key}
                type="button"
                aria-pressed={on}
                onClick={() => {
                  setKey(x.key);
                  setHover(null);
                }}
                className={cn("relative h-7 rounded-full px-2.5 font-mono text-[11px] transition-colors", on ? "text-ink" : "text-ink-subtle hover:text-ink", focus)}
              >
                {on && <motion.span layoutId={`${baseId}-tab`} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }} className="absolute inset-0 rounded-full bg-surface-raised shadow-sm" />}
                <span className="relative">{x.key}</span>
              </button>
            );
          })}
        </div>
      </div>

      <svg
        viewBox={`0 0 ${W} ${H}`}
        preserveAspectRatio="none"
        role="slider"
        tabIndex={0}
        aria-label={`${label} over the ${s.caption}`}
        aria-valuemin={0}
        aria-valuemax={s.points.length - 1}
        aria-valuenow={idx}
        aria-valuetext={`${s.labels[idx]}: ${fmt(value)}`}
        onPointerMove={onMove}
        onPointerLeave={() => setHover(null)}
        onKeyDown={onKey}
        onBlur={() => setHover(null)}
        className={cn("mt-4 h-[72px] w-full touch-none overflow-visible rounded-xs text-ink", focus)}
      >
        <defs>
          <linearGradient id={`${baseId}-g`} x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor="currentColor" stopOpacity={0.14} />
            <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
          </linearGradient>
        </defs>
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.g key={s.key} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12 } }}>
            <path d={`${d} L${W} ${H} L0 ${H} Z`} fill={`url(#${baseId}-g)`} />
            <motion.path
              d={d}
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinejoin="round"
              strokeLinecap="round"
              vectorEffect="non-scaling-stroke"
              initial={{ pathLength: reduce ? 1 : 0 }}
              animate={{ pathLength: 1 }}
              transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
            />
          </motion.g>
        </AnimatePresence>
        <line x1={pts[idx].x} x2={pts[idx].x} y1={0} y2={H} stroke="currentColor" strokeOpacity={hover === null ? 0 : 0.18} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
      </svg>
      <div className="pointer-events-none relative -mt-[72px] h-[72px]" aria-hidden>
        <motion.span
          className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised bg-volt shadow-sm"
          animate={{ left: `${(pts[idx].x / W) * 100}%`, top: pts[idx].y }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 36 }}
        />
      </div>
      <div className="mt-2 flex justify-between font-mono text-[10.5px] text-ink-subtle">
        <span>{s.labels[0]}</span>
        <span>{s.labels[s.labels.length - 1]}</span>
      </div>
    </section>
  );
}

```

### components/examples/stat-cards/goal.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { Check, Minus, Plus, Target } from "lucide-react";
import { cn } from "@/lib/utils";

export interface GoalRingCardProps {
  label?: string;
  /** What one step counts, e.g. "demos booked". */
  unit?: string;
  defaultValue?: number;
  goal?: number;
  /** Deadline caption, e.g. "Ends Oct 31". */
  deadline?: string;
  /** Checkpoints shown under the ring, as fractions of the goal. */
  milestones?: number[];
  onChange?: (value: number) => void;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";
const R = 52;
const C = 2 * Math.PI * R;

/** Goal progress ring: the arc and percentage spring to each new value, milestones tick off, and hitting the goal swaps in a done state. */
export function GoalRingCard({
  label = "Q4 sales goal",
  unit = "demos booked",
  defaultValue = 34,
  goal = 48,
  deadline = "Ends Dec 31",
  milestones = [0.25, 0.5, 0.75, 1],
  onChange,
  className,
}: GoalRingCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [value, setValue] = React.useState(defaultValue);
  const pct = Math.min(1, value / goal);
  const done = value >= goal;

  const mv = useSpring(reduce ? pct : 0, { stiffness: 90, damping: 20 });
  const offset = useTransform(mv, (v) => C * (1 - v));
  const label100 = useTransform(mv, (v) => `${Math.round(v * 100)}%`);
  React.useEffect(() => {
    if (reduce) mv.jump(pct);
    else mv.set(pct);
  }, [pct, reduce, mv]);

  function set(n: number) {
    const next = Math.max(0, Math.min(goal, n));
    setValue(next);
    onChange?.(next);
  }

  return (
    <section aria-label={label} className={cn("w-full max-w-[340px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <div className="flex items-center justify-between">
        <p className="inline-flex items-center gap-1.5 text-[13px] font-medium text-ink-muted">
          <Target aria-hidden className="size-4" />
          {label}
        </p>
        <span className="font-mono text-[11px] text-ink-subtle">{deadline}</span>
      </div>

      <div className="mt-4 flex items-center gap-5">
        <div className="relative size-[132px] shrink-0">
          <svg viewBox="0 0 132 132" className="size-full -rotate-90" role="progressbar" aria-label={`${label} progress`} aria-valuemin={0} aria-valuemax={goal} aria-valuenow={value} aria-valuetext={`${value} of ${goal} ${unit}`}>
            <circle cx="66" cy="66" r={R} fill="none" strokeWidth="12" className="stroke-surface-sunken" />
            <motion.circle cx="66" cy="66" r={R} fill="none" strokeWidth="12" strokeLinecap="round" strokeDasharray={C} style={{ strokeDashoffset: offset }} className="stroke-volt" />
            {milestones.slice(0, -1).map((m) => {
              const a = m * 2 * Math.PI;
              return <circle key={m} cx={66 + R * Math.cos(a)} cy={66 + R * Math.sin(a)} r="2" className={cn("transition-colors", pct >= m ? "fill-on-volt/40" : "fill-ink-subtle/50")} />;
            })}
          </svg>
          <div className="absolute inset-0 grid place-items-center text-center">
            <AnimatePresence mode="popLayout" initial={false}>
              {done ? (
                <motion.span
                  key="done"
                  initial={reduce ? { opacity: 0 } : { scale: 0.3, opacity: 0 }}
                  animate={{ scale: 1, opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ type: "spring", stiffness: 420, damping: 16 }}
                  className="grid size-12 place-items-center rounded-full bg-volt text-on-volt"
                >
                  <Check aria-hidden className="size-6" strokeWidth={3} />
                </motion.span>
              ) : (
                <motion.span key="pct" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="font-display text-[28px] font-semibold tracking-[-0.8px] text-ink tabular-nums">
                  <motion.span>{label100}</motion.span>
                </motion.span>
              )}
            </AnimatePresence>
          </div>
        </div>

        <div className="min-w-0">
          <p className="font-display text-[28px] font-semibold leading-none tracking-[-0.8px] text-ink tabular-nums">
            {value}
            <span className="text-base font-medium text-ink-subtle"> / {goal}</span>
          </p>
          <p className="mt-1 text-xs text-ink-muted">{unit}</p>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={done ? "d" : "r"}
              initial={{ opacity: 0, filter: "blur(6px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(6px)" }}
              className={cn("mt-3 text-xs font-medium", done ? "text-volt-ink" : "text-ink")}
            >
              {done ? "Goal reached" : `${goal - value} to go`}
            </motion.p>
          </AnimatePresence>
        </div>
      </div>

      <ol aria-label="Milestones" className="mt-5 grid grid-cols-4 gap-1.5">
        {milestones.map((m) => {
          const hit = pct >= m;
          return (
            <li key={m} className="text-center">
              <span className="block h-1 overflow-hidden rounded-full bg-surface-sunken">
                <motion.span className="block h-full origin-left rounded-full bg-ink" animate={{ scaleX: hit ? 1 : 0 }} transition={{ type: "spring", stiffness: 260, damping: 28 }} />
              </span>
              <span className={cn("mt-1 block font-mono text-[10.5px]", hit ? "text-ink" : "text-ink-subtle")}>
                {Math.round(m * goal)}
                <span className="sr-only">{hit ? " reached" : " not reached"}</span>
              </span>
            </li>
          );
        })}
      </ol>

      <div className="mt-4 flex items-center gap-2 border-t border-border pt-4">
        <motion.button type="button" aria-label="Remove one" whileTap={reduce ? undefined : { scale: 0.92 }} onClick={() => set(value - 1)} disabled={value <= 0} className={cn("grid size-9 place-items-center rounded-full border border-border-strong/40 text-ink hover:bg-surface-hover disabled:opacity-40", focus)}>
          <Minus aria-hidden className="size-4" />
        </motion.button>
        <motion.button type="button" whileTap={reduce ? undefined : { scale: 0.96 }} onClick={() => set(value + 1)} disabled={done} className={cn("inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-full bg-volt text-[13px] font-semibold text-on-volt disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle", focus)}>
          <Plus aria-hidden className="size-4" />
          Log one
        </motion.button>
      </div>
    </section>
  );
}

```

### components/examples/stat-cards/comparison.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ComparisonMetric {
  key: string;
  label: string;
  current: number;
  previous: number;
  /** Prefix such as "$". */
  prefix?: string;
  suffix?: string;
  /** A drop is good (e.g. refund rate). */
  invert?: boolean;
}

export interface ComparisonStatCardProps {
  title?: string;
  currentLabel?: string;
  previousLabel?: string;
  metrics?: ComparisonMetric[];
  defaultKey?: string;
  className?: string;
}

const DEFAULT_METRICS: ComparisonMetric[] = [
  { key: "revenue", label: "Revenue", current: 48210, previous: 39870, prefix: "$" },
  { key: "orders", label: "Orders", current: 1284, previous: 1342 },
  { key: "aov", label: "Avg. order", current: 37.55, previous: 29.71, prefix: "$" },
  { key: "refunds", label: "Refunds", current: 1.8, previous: 2.6, suffix: "%", invert: true },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

function format(m: ComparisonMetric, n: number) {
  const digits = n % 1 ? 2 : 0;
  return `${m.prefix ?? ""}${n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}${m.suffix ?? ""}`;
}

/** Two-period comparison: pick a metric and both bars spring to their new lengths while the delta crossfades. */
export function ComparisonStatCard({
  title = "This month vs last",
  currentLabel = "Oct 1 – 31",
  previousLabel = "Sep 1 – 30",
  metrics = DEFAULT_METRICS,
  defaultKey,
  className,
}: ComparisonStatCardProps) {
  const reduce = useReducedMotion() ?? false;
  const baseId = React.useId();
  const [key, setKey] = React.useState(defaultKey ?? metrics[0]?.key);
  const m = metrics.find((x) => x.key === key) ?? metrics[0];
  const max = Math.max(m.current, m.previous) || 1;
  const change = m.previous ? (m.current - m.previous) / m.previous : 0;
  const up = change >= 0;
  const good = m.invert ? !up : up;
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 170, damping: 22 };

  const rows = [
    { label: currentLabel, value: m.current, bar: "bg-volt", text: "text-ink" },
    { label: previousLabel, value: m.previous, bar: "bg-ink-subtle/45", text: "text-ink-muted" },
  ];

  return (
    <section aria-label={title} className={cn("w-full max-w-[400px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <div className="flex items-center justify-between gap-3">
        <h3 className="text-[13px] font-medium text-ink-muted">{title}</h3>
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={m.key}
            initial={{ opacity: 0, filter: "blur(6px)", scale: 0.9 }}
            animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
            exit={{ opacity: 0, filter: "blur(6px)", scale: 0.9 }}
            transition={{ duration: 0.2 }}
            className={cn("inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold", good ? "bg-success/12 text-success" : "bg-danger/12 text-danger")}
          >
            {up ? <ArrowUpRight aria-hidden className="size-3.5" /> : <ArrowDownRight aria-hidden className="size-3.5" />}
            {up ? "+" : "−"}
            {Math.abs(change * 100).toFixed(1)}%<span className="sr-only">{good ? ", better" : ", worse"}</span>
          </motion.span>
        </AnimatePresence>
      </div>

      <div role="tablist" aria-label="Metric" className="mt-3 flex gap-1 overflow-x-auto [scrollbar-width:none]">
        {metrics.map((x) => {
          const on = x.key === m.key;
          return (
            <button
              key={x.key}
              type="button"
              role="tab"
              aria-selected={on}
              aria-controls={`${baseId}-panel`}
              onClick={() => setKey(x.key)}
              onKeyDown={(e) => {
                if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
                const i = metrics.findIndex((y) => y.key === m.key);
                const next = metrics[(i + (e.key === "ArrowRight" ? 1 : -1) + metrics.length) % metrics.length];
                setKey(next.key);
                (e.currentTarget.parentElement?.querySelectorAll("button")[metrics.indexOf(next)] as HTMLButtonElement | undefined)?.focus();
              }}
              tabIndex={on ? 0 : -1}
              className={cn("relative h-8 shrink-0 rounded-full px-3 text-[12.5px] font-medium transition-colors", on ? "text-bg" : "text-ink-muted hover:text-ink", focus)}
            >
              {on && <motion.span layoutId={`${baseId}-m`} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }} className="absolute inset-0 rounded-full bg-ink" />}
              <span className="relative">{x.label}</span>
            </button>
          );
        })}
      </div>

      <div id={`${baseId}-panel`} role="tabpanel" aria-label={m.label} className="mt-5 space-y-4">
        {rows.map((r) => (
          <div key={r.label}>
            <div className="flex items-baseline justify-between gap-3">
              <span className="text-xs text-ink-subtle">{r.label}</span>
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={`${m.key}-${r.value}`}
                  initial={{ opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -6 }}
                  transition={{ duration: 0.2 }}
                  className={cn("font-display text-xl font-semibold tracking-[-0.4px] tabular-nums", r.text)}
                >
                  {format(m, r.value)}
                </motion.span>
              </AnimatePresence>
            </div>
            <div className="mt-1.5 h-3 overflow-hidden rounded-full bg-surface-sunken">
              <motion.div className={cn("h-full origin-left rounded-full", r.bar)} initial={reduce ? false : { scaleX: 0 }} animate={{ scaleX: r.value / max }} transition={spring} />
            </div>
          </div>
        ))}
      </div>
      <p className="mt-4 text-xs text-ink-subtle">
        {m.label} {up ? "rose" : "fell"} by {format(m, Math.abs(Math.round((m.current - m.previous) * 100) / 100))} against the previous period.
      </p>
    </section>
  );
}

```
