{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-ticker",
  "type": "registry:ui",
  "title": "Metric Ticker",
  "description": "A dashboard metric with rolling or tweened updates, an honest delta badge and a live sparkline.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/metric-ticker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface MetricTickerProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Current value. For `format=\"percent\"` pass a fraction (0.124 → 12.4%). */\n  value: number;\n  label: string;\n  format?: \"number\" | \"currency\" | \"percent\";\n  /** ISO 4217 code used when `format=\"currency\"`. */\n  currency?: string;\n  /** Fixed number of fraction digits. */\n  decimals?: number;\n  locale?: string;\n  /**\n   * Relative change to display (0.024 → +2.4%). When omitted, the change from the\n   * previous `value` is used.\n   */\n  delta?: number;\n  /** Treat decreases as good (e.g. latency, cost, churn). */\n  invertDelta?: boolean;\n  /** Rolling odometer digits or a tweened number. */\n  animation?: \"roll\" | \"tween\";\n  /** Transition length in milliseconds. */\n  duration?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Draw a sparkline of recent values. */\n  sparkline?: boolean;\n  /** Values to seed the sparkline with (oldest first). Later updates are appended. */\n  history?: number[];\n  /** Number of points kept for the sparkline. */\n  historyLength?: number;\n  /** Sparkline colour. */\n  accent?: string;\n  /** Small text under the value, e.g. \"vs. last update\". */\n  caption?: string;\n  /** Announce updates through a polite live region. */\n  announce?: boolean;\n}\n\nconst SIZES = {\n  sm: { value: \"text-2xl\", gap: \"gap-1.5\" },\n  md: { value: \"text-4xl\", gap: \"gap-2\" },\n  lg: { value: \"text-5xl sm:text-6xl\", gap: \"gap-2.5\" },\n} as const;\n\nconst DIGITS = [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"];\nconst ROW = 1.1; // em — line box of a single digit\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** One odometer wheel. The transform transition always runs from the previous digit to the new one. */\nfunction RollingDigit({ digit, duration }: { digit: number; duration: number }) {\n  return (\n    <span className=\"relative inline-block overflow-hidden\" style={{ height: `${ROW}em`, lineHeight: ROW }}>\n      <span className=\"invisible\">0</span>\n      <span\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center transition-transform ease-[cubic-bezier(0.22,1,0.36,1)]\"\n        style={{ transform: `translateY(${-digit * ROW}em)`, transitionDuration: `${duration}ms` }}\n      >\n        {DIGITS.map((d) => (\n          <span key={d} style={{ height: `${ROW}em` }}>\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nfunction sparkPath(values: number[]) {\n  const min = Math.min(...values);\n  const max = Math.max(...values);\n  const span = max - min || 1;\n  const pts = values.map((v, i) => [(i / (values.length - 1)) * 100, 28 - ((v - min) / span) * 24] as const);\n  const line = pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x.toFixed(2)},${y.toFixed(2)}`).join(\"\");\n  return { line, area: `${line}L100,32L0,32Z`, last: pts[pts.length - 1] };\n}\n\n/**\n * A dashboard metric that transitions honestly: every update animates from the previous value\n * to the new one (never from zero), with a delta badge that uses arrow + sign + colour.\n */\nexport function MetricTicker({\n  value,\n  label,\n  format = \"number\",\n  currency = \"USD\",\n  decimals = 0,\n  locale = \"en-US\",\n  delta,\n  invertDelta = false,\n  animation = \"roll\",\n  duration = 600,\n  size = \"md\",\n  sparkline = true,\n  history,\n  historyLength = 24,\n  accent = \"#FF7448\",\n  caption,\n  announce = true,\n  className = \"\",\n  style,\n  ...props\n}: MetricTickerProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const labelId = React.useId();\n  const valueRef = React.useRef<HTMLSpanElement>(null);\n  const tweenRef = React.useRef<HTMLSpanElement>(null);\n  const shownRef = React.useRef(value);\n\n  // Track the previous value and a rolling history by comparing during render (no effect round-trip).\n  const [series, setSeries] = React.useState(() => ({\n    last: value,\n    previous: value,\n    updates: 0,\n    points: [...(history ?? []), value].slice(-historyLength),\n  }));\n  if (series.last !== value) {\n    setSeries({\n      last: value,\n      previous: series.last,\n      updates: series.updates + 1,\n      points: [...series.points, value].slice(-historyLength),\n    });\n  }\n\n  const formatter = React.useMemo(() => {\n    const digits = Math.max(0, Math.min(20, Math.round(decimals)));\n    const options: Intl.NumberFormatOptions = {\n      style: format === \"currency\" ? \"currency\" : format === \"percent\" ? \"percent\" : \"decimal\",\n      minimumFractionDigits: digits,\n      maximumFractionDigits: digits,\n    };\n    if (format === \"currency\") options.currency = currency;\n    try {\n      return new Intl.NumberFormat(locale, options);\n    } catch {\n      return new Intl.NumberFormat(\"en-US\", { ...options, currency: format === \"currency\" ? \"USD\" : undefined });\n    }\n  }, [format, currency, decimals, locale]);\n\n  const deltaFormatter = React.useMemo(() => {\n    try {\n      return new Intl.NumberFormat(locale, { style: \"percent\", minimumFractionDigits: 2, maximumFractionDigits: 2 });\n    } catch {\n      return new Intl.NumberFormat(\"en-US\", { style: \"percent\", minimumFractionDigits: 2, maximumFractionDigits: 2 });\n    }\n  }, [locale]);\n\n  const formatted = formatter.format(value);\n  const change =\n    delta ?? (series.previous === 0 ? 0 : (value - series.previous) / Math.abs(series.previous));\n  const direction = change > 0 ? \"up\" : change < 0 ? \"down\" : \"flat\";\n  const good = direction === \"flat\" ? null : invertDelta ? direction === \"down\" : direction === \"up\";\n  const tone =\n    good === null\n      ? \"text-zinc-500 bg-zinc-900/5 dark:text-zinc-400 dark:bg-white/5\"\n      : good\n        ? \"text-emerald-700 bg-emerald-500/10 dark:text-emerald-300 dark:bg-emerald-400/10\"\n        : \"text-rose-700 bg-rose-500/10 dark:text-rose-300 dark:bg-rose-400/10\";\n  const deltaText = `${change > 0 ? \"+\" : change < 0 ? \"−\" : \"±\"}${deltaFormatter.format(Math.abs(change))}`;\n\n  // Tween mode: write frames straight to the DOM, starting from the number currently shown.\n  // A layout effect restores the start value before paint, so the new value never flashes first.\n  React.useLayoutEffect(() => {\n    if (animation !== \"tween\") {\n      shownRef.current = value;\n      return;\n    }\n    const node = tweenRef.current;\n    if (!node) return;\n    const from = shownRef.current;\n    if (reduceMotion || duration <= 0 || from === value) {\n      shownRef.current = value;\n      node.textContent = formatter.format(value);\n      return;\n    }\n    node.textContent = formatter.format(from);\n    let frame = 0;\n    let start: number | null = null;\n    const tick = (now: number) => {\n      if (start === null) start = now;\n      const t = Math.min(1, (now - start) / duration);\n      const eased = 1 - Math.pow(1 - t, 3);\n      const current = t === 1 ? value : from + (value - from) * eased;\n      shownRef.current = current;\n      node.textContent = formatter.format(current);\n      if (t < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [value, animation, duration, reduceMotion, formatter]);\n\n  // A brief directional wash on update (skipped on mount and with reduced motion).\n  const updates = series.updates;\n  React.useEffect(() => {\n    const node = valueRef.current;\n    if (!node || updates === 0 || reduceMotion || good === null || typeof node.animate !== \"function\") return;\n    const colour = good ? \"rgba(16, 185, 129, 0.16)\" : \"rgba(244, 63, 94, 0.16)\";\n    const anim = node.animate([{ backgroundColor: colour }, { backgroundColor: \"rgba(0, 0, 0, 0)\" }], {\n      duration: Math.max(500, duration * 1.6),\n      easing: \"ease-out\",\n    });\n    return () => anim.cancel();\n  }, [updates, good, reduceMotion, duration]);\n\n  const sizes = SIZES[size] ?? SIZES.md;\n  const chars = formatted.split(\"\");\n  const spark = sparkline && series.points.length > 1 ? sparkPath(series.points) : null;\n  const rollDuration = reduceMotion ? 0 : duration;\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={labelId}\n      className={`flex flex-col ${sizes.gap} text-zinc-950 dark:text-zinc-50 ${className}`}\n      style={{ [\"--mt-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className=\"flex items-center justify-between gap-3\">\n        <span id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </span>\n        <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[11px] tabular-nums ${tone}`}>\n          <svg viewBox=\"0 0 10 10\" className=\"size-2.5\" aria-hidden=\"true\">\n            {direction === \"flat\" ? (\n              <rect x=\"1.5\" y=\"4.4\" width=\"7\" height=\"1.2\" rx=\"0.6\" fill=\"currentColor\" />\n            ) : (\n              <path d={direction === \"up\" ? \"M5 1.5 8.5 7h-7z\" : \"M5 8.5 1.5 3h7z\"} fill=\"currentColor\" />\n            )}\n          </svg>\n          <span aria-hidden=\"true\">{deltaText}</span>\n          <span className=\"sr-only\">\n            {direction === \"flat\" ? \"unchanged\" : `${direction} ${deltaFormatter.format(Math.abs(change))}`}\n            {good === null ? \"\" : good ? \" (favourable)\" : \" (unfavourable)\"}\n          </span>\n        </span>\n      </div>\n\n      <span\n        ref={valueRef}\n        aria-hidden=\"true\"\n        className={`-mx-1 inline-flex w-fit items-baseline rounded-md px-1 font-semibold tracking-[-0.035em] whitespace-nowrap tabular-nums ${sizes.value}`}\n      >\n        {animation === \"roll\" ? (\n          // Keys count from the right so units, tens… keep their wheel when the length changes.\n          chars.map((ch, i) => {\n            const key = chars.length - i;\n            return /\\d/.test(ch) ? (\n              <RollingDigit key={key} digit={Number(ch)} duration={rollDuration} />\n            ) : (\n              <span key={key} style={{ lineHeight: ROW }}>\n                {ch}\n              </span>\n            );\n          })\n        ) : (\n          <span ref={tweenRef} style={{ lineHeight: ROW }} suppressHydrationWarning>\n            {formatted}\n          </span>\n        )}\n      </span>\n      <span className=\"sr-only\">{formatted}</span>\n\n      {spark ? (\n        <div className=\"relative h-8 w-full\" aria-hidden=\"true\">\n          <svg viewBox=\"0 0 100 32\" preserveAspectRatio=\"none\" className=\"absolute inset-0 h-full w-full overflow-visible\">\n            <path d={spark.area} fill=\"var(--mt-accent)\" opacity={0.12} />\n            <path d={spark.line} fill=\"none\" stroke=\"var(--mt-accent)\" strokeWidth={1.5} vectorEffect=\"non-scaling-stroke\" strokeLinejoin=\"round\" />\n          </svg>\n          <span\n            className=\"absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--mt-accent)] ring-2 ring-white dark:ring-zinc-950\"\n            style={{ left: `${spark.last[0]}%`, top: `${(spark.last[1] / 32) * 100}%` }}\n          />\n        </div>\n      ) : null}\n\n      {caption ? <span className=\"text-xs text-zinc-500 dark:text-zinc-400\">{caption}</span> : null}\n\n      {announce ? (\n        <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n          {series.updates > 0\n            ? `${label} ${formatted}, ${direction === \"flat\" ? \"unchanged\" : `${direction} ${deltaFormatter.format(Math.abs(change))}`}`\n            : \"\"}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/metric-ticker"
}
