{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-card",
  "type": "registry:component",
  "title": "Metric Card",
  "description": "One number with its own hand-drawn chart: switch the range and the lines reshape, scrub with a pointer or the arrow keys for exact figures, and the headline follows wherever you are.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/metric-card.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowDownRightIcon, ArrowUpRightIcon } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type KeyboardEvent, type PointerEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MetricSeries {\n  name: string;\n  color?: string;\n}\n\nexport interface MetricPoint {\n  label: string;\n  values: number[];\n}\n\nexport interface MetricRange {\n  id: string;\n  label: string;\n  caption?: string;\n  points: MetricPoint[];\n}\n\nexport interface MetricCardProps {\n  title?: string;\n  series?: MetricSeries[];\n  ranges?: MetricRange[];\n  defaultRange?: string;\n  format?: Intl.NumberFormatOptions;\n  locale?: string;\n  className?: string;\n}\n\nconst CHART_COLORS = [\n  \"var(--color-chart-1)\",\n  \"var(--color-chart-2)\",\n  \"var(--color-chart-3)\",\n  \"var(--color-chart-4)\",\n  \"var(--color-chart-5)\",\n];\nconst HEIGHT = 168;\nconst PAD_TOP = 10;\nconst PAD_BOTTOM = 22;\nconst AXIS = 34;\nconst SAMPLES = 180;\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let place = 0;\n  let other = 0;\n  // Key places from the right so existing digits keep their wheel when the number grows.\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${place++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction sampleCurve(values: number[], count: number) {\n  const n = values.length;\n  if (n === 1) return Array.from({ length: count }, () => values[0]);\n  const at = (i: number) => values[Math.max(0, Math.min(n - 1, i))];\n  return Array.from({ length: count }, (_, k) => {\n    const u = (k / (count - 1)) * (n - 1);\n    const i = Math.min(n - 2, Math.floor(u));\n    const s = u - i;\n    const p0 = at(i - 1);\n    const p1 = at(i);\n    const p2 = at(i + 1);\n    const p3 = at(i + 2);\n    return (\n      0.5 *\n      (2 * p1 +\n        (-p0 + p2) * s +\n        (2 * p0 - 5 * p1 + 4 * p2 - p3) * s * s +\n        (-p0 + 3 * p1 - 3 * p2 + p3) * s * s * s)\n    );\n  });\n}\n\nfunction mulberry(seed: number) {\n  return () => {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction makeRange(id: string, label: string, caption: string, count: number, stepDays: number, seed: number) {\n  const rand = mulberry(seed);\n  const end = Date.UTC(2026, 9, 23);\n  const bases = [3200, 1900, 900];\n  return {\n    id,\n    label,\n    caption,\n    points: Array.from({ length: count }, (_, i) => {\n      const date = new Date(end - (count - 1 - i) * stepDays * 86400000);\n      const trend = 1 + (i / count) * 0.35;\n      return {\n        label: date.toLocaleDateString(\"en-US\", {\n          month: \"short\",\n          day: stepDays >= 28 ? undefined : \"numeric\",\n          timeZone: \"UTC\",\n        }),\n        values: bases.map((b, s) =>\n          Math.round(b * trend * (0.86 + rand() * 0.28) * (1 + 0.12 * Math.sin(i / (1.6 + s)))),\n        ),\n      };\n    }),\n  };\n}\n\nconst defaultSeries: MetricSeries[] = [{ name: \"Organic\" }, { name: \"Paid\" }, { name: \"Referral\" }];\nconst defaultRanges: MetricRange[] = [\n  makeRange(\"7d\", \"7D\", \"Last 7 days\", 7, 1, 11),\n  makeRange(\"30d\", \"30D\", \"Last 30 days\", 30, 1, 23),\n  makeRange(\"90d\", \"90D\", \"Last 90 days\", 13, 7, 37),\n  makeRange(\"12m\", \"12M\", \"Last 12 months\", 12, 30.4, 51),\n];\n\nexport function MetricCard({\n  title = \"Active users\",\n  series = defaultSeries,\n  ranges = defaultRanges,\n  defaultRange,\n  format = { maximumFractionDigits: 0 },\n  locale = \"en-US\",\n  className,\n}: MetricCardProps) {\n  const reduce = useReducedMotion();\n  const uid = useId().replace(/:/g, \"\");\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(420);\n  const [rangeId, setRangeId] = useState(defaultRange ?? ranges[1]?.id ?? ranges[0].id);\n  const [hidden, setHidden] = useState<Set<number>>(() => new Set());\n  const [active, setActive] = useState<number | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const [scrubbing, setScrubbing] = useState(false);\n\n  useEffect(() => {\n    const el = wrapRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(Math.max(200, entry.contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const range = ranges.find((r) => r.id === rangeId) ?? ranges[0];\n  const points = range.points;\n  const n = points.length;\n  const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n  const axisFmt = useMemo(\n    () => new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }),\n    [locale],\n  );\n\n  const visible = series.map((_, i) => !hidden.has(i));\n  const plotW = width - AXIS;\n  const plotH = HEIGHT - PAD_TOP - PAD_BOTTOM;\n  const rawMax = Math.max(1, ...points.flatMap((p) => p.values.filter((_, i) => visible[i])));\n  const step = niceStep(rawMax / 3);\n  const max = Math.ceil((rawMax * 1.08) / step) * step;\n  const xAt = (i: number) => AXIS + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);\n  const yAt = (v: number) => PAD_TOP + plotH - (Math.max(0, Math.min(max, v)) / max) * plotH;\n\n  const paths = series.map((_, s) => {\n    const ys = sampleCurve(\n      points.map((p) => p.values[s] ?? 0),\n      SAMPLES,\n    );\n    const line = ys\n      .map((v, k) => `${k ? \"L\" : \"M\"}${(AXIS + (k / (SAMPLES - 1)) * plotW).toFixed(2)} ${yAt(v).toFixed(2)}`)\n      .join(\" \");\n    return { line, area: `${line} L${(AXIS + plotW).toFixed(2)} ${PAD_TOP + plotH} L${AXIS} ${PAD_TOP + plotH} Z` };\n  });\n\n  const focusIndex = active ?? n - 1;\n  const total = (i: number) => points[i].values.reduce((sum, v, s) => sum + (visible[s] ? v : 0), 0);\n  const headline = total(focusIndex);\n  const first = total(0);\n  const delta = first ? (headline - first) / first : 0;\n  const up = delta >= 0;\n  const gridValues = Array.from({ length: Math.round(max / step) + 1 }, (_, i) => i * step);\n  const xLabels = n > 2 ? [0, Math.floor((n - 1) / 2), n - 1] : points.map((_, i) => i);\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 260, damping: 30 };\n\n  function onPointerMove(e: PointerEvent<HTMLDivElement>) {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const x = e.clientX - rect.left - AXIS;\n    setScrubbing(true);\n    setActive(Math.max(0, Math.min(n - 1, Math.round((x / plotW) * (n - 1)))));\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    let next: number | null = null;\n    if (e.key === \"ArrowRight\") next = Math.min(n - 1, (active ?? -1) + 1);\n    else if (e.key === \"ArrowLeft\") next = Math.max(0, (active ?? n) - 1);\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = n - 1;\n    else if (e.key === \"Escape\") {\n      setActive(null);\n      return;\n    } else return;\n    e.preventDefault();\n    setScrubbing(true);\n    setActive(next);\n    const p = points[next];\n    setAnnounce(\n      `${p.label}: ${series\n        .map((s, i) => (visible[i] ? `${s.name} ${fmt.format(p.values[i] ?? 0)}` : null))\n        .filter(Boolean)\n        .join(\", \")}`,\n    );\n  }\n\n  const tipLeft = active === null ? 0 : xAt(active);\n  const flip = tipLeft > width * 0.58;\n\n  return (\n    <div className={cn(\"w-full max-w-[480px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}>\n      <div className=\"flex flex-wrap items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <span className=\"text-muted-foreground text-sm\">{title}</span>\n          <Roll className=\"font-semibold text-3xl tracking-tight\" instant={scrubbing} text={fmt.format(headline)} />\n          <div className=\"flex items-center gap-2 text-xs\">\n            <span\n              className={cn(\n                \"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium tabular-nums\",\n                up ? \"bg-success/10 text-success-foreground\" : \"bg-destructive/10 text-destructive-foreground\",\n              )}\n            >\n              {up ? <ArrowUpRightIcon className=\"size-3\" /> : <ArrowDownRightIcon className=\"size-3\" />}\n              <Roll instant={scrubbing} text={`${(Math.abs(delta) * 100).toFixed(1)}%`} />\n            </span>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ opacity: 1, y: 0 }}\n                className=\"text-muted-foreground\"\n                exit={{ opacity: 0, y: -4 }}\n                initial={{ opacity: 0, y: 4 }}\n                key={range.id}\n                transition={{ duration: reduce || scrubbing ? 0 : 0.15, ease: \"easeOut\" }}\n              >\n                {active === null ? (range.caption ?? range.label) : `${points[active].label} vs ${points[0].label}`}\n              </motion.span>\n            </AnimatePresence>\n          </div>\n        </div>\n        <Tabs\n          onValueChange={(v) => {\n            setScrubbing(false);\n            setRangeId(String(v));\n            setActive(null);\n          }}\n          value={rangeId}\n        >\n          <TabsList aria-label=\"Range\">\n            {ranges.map((r) => (\n              <TabsTab className=\"px-2 text-xs tabular-nums text-muted-foreground sm:text-xs\" key={r.id} value={r.id}>\n                {r.label}\n              </TabsTab>\n            ))}\n          </TabsList>\n        </Tabs>\n      </div>\n\n      <div\n        aria-label={`${title} chart. Use the arrow keys to inspect points.`}\n        className=\"relative mt-4 cursor-crosshair touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        onBlur={() => {\n          setActive(null);\n          setScrubbing(false);\n        }}\n        onKeyDown={onKeyDown}\n        onPointerLeave={() => {\n          setActive(null);\n        }}\n        onPointerMove={onPointerMove}\n        ref={wrapRef}\n        role=\"application\"\n        tabIndex={0}\n      >\n        <svg aria-hidden className=\"block overflow-visible\" height={HEIGHT} width={width}>\n          <defs>\n            {series.map((s, i) => (\n              <linearGradient id={`${uid}-fill-${i}`} key={s.name} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                <stop offset=\"0%\" style={{ stopColor: s.color ?? CHART_COLORS[i % 5], stopOpacity: 0.22 }} />\n                <stop offset=\"100%\" style={{ stopColor: s.color ?? CHART_COLORS[i % 5], stopOpacity: 0 }} />\n              </linearGradient>\n            ))}\n          </defs>\n\n          {gridValues.map((v) => (\n            <motion.g animate={{ y: yAt(v), opacity: 1 }} initial={{ y: yAt(v), opacity: 0 }} key={v} transition={spring}>\n              <line className=\"stroke-border\" strokeDasharray={v === 0 ? undefined : \"2 4\"} x1={AXIS} x2={width} y1={0} y2={0} />\n              <text className=\"fill-muted-foreground text-[10px] tabular-nums\" dominantBaseline=\"middle\" x={0} y={0}>\n                {axisFmt.format(v)}\n              </text>\n            </motion.g>\n          ))}\n\n          {xLabels.map((i, k) => (\n            <text\n              className=\"fill-muted-foreground text-[10px]\"\n              key={`${range.id}-${i}`}\n              textAnchor={k === 0 ? \"start\" : k === xLabels.length - 1 ? \"end\" : \"middle\"}\n              x={xAt(i)}\n              y={HEIGHT - 4}\n            >\n              {points[i].label}\n            </text>\n          ))}\n\n          {series.map((s, i) => (\n            <motion.path\n              animate={{ d: paths[i].area, opacity: visible[i] && i === 0 ? 1 : 0 }}\n              d={paths[i].area}\n              fill={`url(#${uid}-fill-${i})`}\n              initial={false}\n              key={`area-${s.name}`}\n              transition={spring}\n            />\n          ))}\n          {series.map((s, i) => (\n            <motion.path\n              animate={{ d: paths[i].line, opacity: visible[i] ? 1 : 0, pathLength: 1 }}\n              d={paths[i].line}\n              fill=\"none\"\n              initial={{ pathLength: 0 }}\n              key={`line-${s.name}`}\n              stroke={s.color ?? CHART_COLORS[i % 5]}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={i === 0 ? 2 : 1.5}\n              transition={{ ...spring, pathLength: reduce ? { duration: 0 } : { duration: 0.6, ease: [0.23, 1, 0.32, 1] } }}\n            />\n          ))}\n\n          <AnimatePresence>\n            {active !== null && (\n              <motion.g\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                transition={{ duration: reduce ? 0 : 0.12 }}\n              >\n                <motion.line\n                  animate={{ x1: xAt(active), x2: xAt(active) }}\n                  className=\"stroke-foreground/25\"\n                  initial={false}\n                  strokeDasharray=\"3 3\"\n                  transition={spring}\n                  x1={xAt(active)}\n                  x2={xAt(active)}\n                  y1={PAD_TOP - 4}\n                  y2={PAD_TOP + plotH}\n                />\n                {series.map((s, i) =>\n                  visible[i] ? (\n                    <motion.circle\n                      animate={{ cx: xAt(active), cy: yAt(points[active].values[i] ?? 0) }}\n                      className=\"stroke-card\"\n                      cx={xAt(active)}\n                      cy={yAt(points[active].values[i] ?? 0)}\n                      fill={s.color ?? CHART_COLORS[i % 5]}\n                      initial={false}\n                      key={s.name}\n                      r={4}\n                      strokeWidth={2}\n                      transition={spring}\n                    />\n                  ) : null,\n                )}\n              </motion.g>\n            )}\n          </AnimatePresence>\n        </svg>\n\n        <AnimatePresence>\n          {active !== null && (\n            <motion.div\n              animate={{ opacity: 1, scale: 1, x: flip ? tipLeft - 12 : tipLeft + 12 }}\n              className={cn(\n                \"pointer-events-none absolute top-0 left-0 z-10 min-w-36 rounded-xl border bg-popover p-2.5 text-popover-foreground text-xs shadow-md/5 transition-[translate] duration-200 ease-out\",\n                flip && \"-translate-x-full\",\n              )}\n              exit={{ opacity: 0, scale: 0.96, transition: { duration: reduce ? 0 : 0.1 } }}\n              initial={{ opacity: 0, scale: 0.96, x: flip ? tipLeft - 12 : tipLeft + 12 }}\n              style={{ originX: flip ? 1 : 0, originY: 0 }}\n              transition={spring}\n            >\n              <div className=\"mb-1.5 font-medium\">{points[active].label}</div>\n              <div className=\"flex flex-col gap-1\">\n                {series.map((s, i) =>\n                  visible[i] ? (\n                    <div className=\"flex items-center gap-2\" key={s.name}>\n                      <span className=\"size-2 rounded-full\" style={{ background: s.color ?? CHART_COLORS[i % 5] }} />\n                      <span className=\"text-muted-foreground\">{s.name}</span>\n                      <span className=\"ms-auto font-medium tabular-nums\">{fmt.format(points[active].values[i] ?? 0)}</span>\n                    </div>\n                  ) : null,\n                )}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </div>\n\n      <div className=\"mt-3 flex flex-wrap gap-1.5\">\n        {series.map((s, i) => (\n          <button\n            aria-pressed={visible[i]}\n            className={cn(\n              \"relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs outline-none transition-[opacity,background-color,transform] duration-150 ease-out after:absolute after:-inset-y-0.5 after:inset-x-0 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring motion-safe:active:scale-[0.97]\",\n              !visible[i] && \"opacity-50\",\n            )}\n            key={s.name}\n            onClick={() => {\n              setScrubbing(false);\n              setHidden((prev) => {\n                const next = new Set(prev);\n                if (next.has(i)) next.delete(i);\n                else if (next.size < series.length - 1) next.add(i);\n                return next;\n              });\n            }}\n            type=\"button\"\n          >\n            <motion.span\n              animate={{ scale: visible[i] ? 1 : 0.5 }}\n              className=\"size-2 rounded-full\"\n              style={{ background: s.color ?? CHART_COLORS[i % 5] }}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.25, bounce: 0 }}\n            />\n            {s.name}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction niceStep(raw: number) {\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  const unit = raw / pow;\n  return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10) * pow;\n}\n"
    }
  ]
}