{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stat-counter","type":"registry:component","title":"Stat counter","description":"Numbers count up to their value; scrub a sparkline and the figure rolls back through its history.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/stat-counter.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Stat = {\n  label: string;\n  value: number;\n  format: Intl.NumberFormat;\n  /** Change against the previous period, as a fraction: 0.12 is +12%. */\n  trend: number;\n  /** Which direction is good news. Latency going up is not. */\n  goodWhen?: \"up\" | \"down\";\n  series: number[];\n};\n\n// Long enough to read as counting, which is the point of the component;\n// shorter and the number just blinks to its value. No bounce, so a figure\n// never overshoots to a value that isn't true.\nconst COUNT = { type: \"spring\", visualDuration: 0.7, bounce: 0 } as const;\n// Scrubbing the line glides between figures faster, so the number keeps up\n// with the finger, but still counts rather than blinking.\nconst SCRUB = { type: \"spring\", visualDuration: 0.3, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst SPARK_W = 120;\nconst SPARK_H = 32;\n// Cards wipe their sparklines in one after another, 50ms apart.\nconst STAGGER_MS = 50;\n\nconst trendFormat = new Intl.NumberFormat(\"en-US\", {\n  style: \"percent\",\n  maximumFractionDigits: 1,\n  signDisplay: \"exceptZero\",\n});\n\nexport function StatCounter({\n  stats,\n  className,\n}: {\n  stats: Stat[];\n  className?: string;\n}) {\n  const ref = useRef<HTMLDListElement>(null);\n  const inView = useInView(ref, { once: true, amount: 0.5 });\n\n  return (\n    // Four across when there's room, two by two on a phone.\n    <div className={cn(\"@container w-[600px] max-w-full\", className)}>\n      <dl ref={ref} className=\"grid grid-cols-2 gap-3 @min-[560px]:grid-cols-4\">\n        {stats.map((stat, i) => (\n          <StatCard key={stat.label} stat={stat} started={inView} index={i} />\n        ))}\n      </dl>\n    </div>\n  );\n}\n\nfunction StatCard({ stat, started, index }: { stat: Stat; started: boolean; index: number }) {\n  const reduceMotion = useReducedMotion();\n  const value = useMotionValue(0);\n  // Written straight to the DOM each frame; the card never re-renders\n  // while counting.\n  const text = useTransform(value, (v) => stat.format.format(v));\n  // The point being inspected, or null for \"now\". Counted in whole points,\n  // so moving along the line re-renders at most once per point.\n  const [scrub, setScrub] = useState<number | null>(null);\n  const last = stat.series.length - 1;\n  const shown = scrub === null ? stat.value : stat.series[scrub];\n\n  useEffect(() => {\n    if (!started) return;\n    // Retargets from wherever the number is, so a refresh or a scrub\n    // mid-count carries on smoothly from the old figure.\n    const controls = animate(\n      value,\n      shown,\n      reduceMotion ? { duration: 0 } : scrub === null ? COUNT : SCRUB,\n    );\n    return () => controls.stop();\n  }, [started, shown, scrub, reduceMotion, value]);\n\n  const up = stat.trend >= 0;\n  const good = up === ((stat.goodWhen ?? \"up\") === \"up\");\n  const trendText = trendFormat.format(stat.trend);\n  const when = scrub === null ? null : ago(last - scrub);\n\n  const pick = (e: React.PointerEvent<HTMLDivElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const t = Math.min(Math.max((e.clientX - box.left) / box.width, 0), 1);\n    setScrub(Math.round(t * last));\n  };\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1 rounded-2xl bg-muted p-4\">\n      <dt className=\"text-sm text-muted-foreground\">{stat.label}</dt>\n      <dd className=\"flex flex-col gap-3\">\n        <span className=\"sr-only\">\n          {stat.format.format(stat.value)}, {trendText}\n        </span>\n        <motion.span\n          aria-hidden\n          className={cn(\n            \"truncate text-2xl font-semibold tracking-tight tabular-nums transition-[color] duration-150 ease-out\",\n            // Muted while it shows the past, so a glance never mistakes an\n            // old figure for today's.\n            scrub === null || scrub === last ? \"text-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {text}\n        </motion.span>\n        {/* Trend and date share one cell and swap through a blur, so the chip\n            never jumps in width mid-scrub. */}\n        <span aria-hidden className=\"grid h-6 self-start\">\n          <span\n            className={cn(\n              \"col-start-1 row-start-1 inline-flex items-center gap-1 self-start rounded-full bg-background px-2 text-xs leading-6 font-medium tabular-nums transition-[opacity,filter] ease-out\",\n              good ? \"text-foreground\" : \"text-destructive\",\n              when ? \"opacity-0 blur-[4px] duration-100\" : \"duration-200\",\n            )}\n          >\n            <svg\n              viewBox=\"0 0 12 12\"\n              className={cn(\n                \"size-3 transition-transform duration-200 motion-reduce:transition-none\",\n                \"ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                !up && \"rotate-180\",\n              )}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M6 9.5v-7M3 5.5l3-3 3 3\" />\n            </svg>\n            {/* Remounts when the figure changes, so it fades in from a soft\n                blur rather than snapping. */}\n            <span\n              key={trendText}\n              className=\"transition-[opacity,filter] duration-200 ease-out starting:opacity-0 starting:blur-[4px]\"\n            >\n              {trendText}\n            </span>\n          </span>\n          <span\n            className={cn(\n              \"col-start-1 row-start-1 self-start rounded-full bg-background px-2 text-xs leading-6 font-medium text-muted-foreground tabular-nums transition-[opacity,filter] ease-out\",\n              when ? \"duration-200\" : \"opacity-0 blur-[4px] duration-100\",\n            )}\n          >\n            {when ?? ago(0)}\n          </span>\n        </span>\n        <div\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={`${stat.label} history`}\n          aria-valuemin={0}\n          aria-valuemax={last}\n          aria-valuenow={scrub ?? last}\n          aria-valuetext={`${stat.format.format(shown)}, ${ago(last - (scrub ?? last))}`}\n          onPointerMove={(e) => {\n            if (e.pointerType === \"mouse\" || e.buttons) pick(e);\n          }}\n          onPointerDown={(e) => {\n            // Touch scrubs by dragging along the line.\n            e.currentTarget.setPointerCapture(e.pointerId);\n            pick(e);\n          }}\n          onPointerLeave={(e) => {\n            if (!e.currentTarget.hasPointerCapture(e.pointerId)) setScrub(null);\n          }}\n          onPointerUp={(e) => {\n            if (e.pointerType !== \"mouse\") setScrub(null);\n          }}\n          onPointerCancel={() => setScrub(null)}\n          onBlur={() => setScrub(null)}\n          onKeyDown={(e) => {\n            const current = scrub ?? last;\n            const next =\n              e.key === \"ArrowLeft\"\n                ? current - 1\n                : e.key === \"ArrowRight\"\n                  ? current + 1\n                  : e.key === \"Home\"\n                    ? 0\n                    : e.key === \"End\"\n                      ? last\n                      : e.key === \"Escape\"\n                        ? last\n                        : null;\n            if (next === null) return;\n            e.preventDefault();\n            const clamped = Math.min(Math.max(next, 0), last);\n            setScrub(clamped === last ? null : clamped);\n          }}\n          // A few px of padding widens the target without moving the line.\n          className=\"-mx-1 -my-1 cursor-ew-resize touch-pan-y rounded-lg px-1 py-1 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n        >\n          <Sparkline\n            series={stat.series}\n            started={started}\n            delay={index * STAGGER_MS}\n            scrub={scrub}\n          />\n        </div>\n      </dd>\n    </div>\n  );\n}\n\nfunction ago(days: number) {\n  return days === 0 ? \"Today\" : days === 1 ? \"Yesterday\" : `${days} days ago`;\n}\n\nfunction points(series: number[]) {\n  const min = Math.min(...series);\n  const max = Math.max(...series);\n  const span = max - min || 1;\n  // 2px inset so the 1.5px line is never clipped at the extremes.\n  return series.map((v, i) => ({\n    x: (i / (series.length - 1)) * SPARK_W,\n    y: 2 + (1 - (v - min) / span) * (SPARK_H - 4),\n  }));\n}\n\nfunction pathFor(series: number[]) {\n  return points(series)\n    .map((p, i) => `${i ? \"L\" : \"M\"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)\n    .join(\" \");\n}\n\nfunction Sparkline({\n  series,\n  started,\n  delay,\n  scrub,\n}: {\n  series: number[];\n  started: boolean;\n  delay: number;\n  scrub: number | null;\n}) {\n  const reduceMotion = useReducedMotion();\n  const d = pathFor(series);\n  const pts = points(series);\n  const at = scrub === null ? null : pts[scrub];\n  // Past the inspected point the line is the future, so it fades back.\n  const cut = scrub === null ? 0 : (1 - scrub / (series.length - 1)) * 100;\n  const line = (\n    <motion.path\n      initial={false}\n      animate={{ d }}\n      transition={reduceMotion ? { duration: 0 } : { duration: 0.5, ease: EASE_OUT }}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      vectorEffect=\"non-scaling-stroke\"\n    />\n  );\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        \"relative h-8 w-full\",\n        // Wipes in left to right, the direction time runs along the line.\n        // 500ms because it plays once, on arrival, alongside the count.\n        \"transition-[clip-path] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n        started ? \"[clip-path:inset(-4px)]\" : \"[clip-path:inset(-4px_100%_-4px_-4px)]\",\n      )}\n      style={{ transitionDelay: `${delay}ms` }}\n    >\n      <svg\n        viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}\n        preserveAspectRatio=\"none\"\n        className={cn(\n          \"absolute inset-0 size-full overflow-visible text-foreground/60 transition-[opacity] duration-150 ease-out\",\n          scrub !== null && \"opacity-30\",\n        )}\n      >\n        {line}\n      </svg>\n      {/* The same line again, clipped at the inspected point, so everything\n          up to it stays at full strength. Clip-path rather than a second\n          shorter path, so the two can never disagree about the shape. */}\n      <svg\n        viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}\n        preserveAspectRatio=\"none\"\n        className={cn(\n          \"absolute inset-0 size-full overflow-visible text-foreground/60 transition-[clip-path,opacity] duration-150 ease-out\",\n          scrub === null && \"opacity-0\",\n        )}\n        style={{ clipPath: `inset(-4px ${cut}% -4px -4px)` }}\n      >\n        {line}\n      </svg>\n      {/* Left and top in percent, since the line stretches with the card and\n          only percentages track it; a 150ms glide between neighbouring\n          points on an element this small costs nothing noticeable. */}\n      <span\n        className={cn(\n          \"pointer-events-none absolute inset-y-0 w-px bg-foreground/20 transition-[left,opacity] duration-150 ease-out\",\n          at ? \"opacity-100\" : \"opacity-0\",\n        )}\n        style={{ left: `${((at?.x ?? SPARK_W) / SPARK_W) * 100}%` }}\n      />\n      <span\n        className={cn(\n          \"pointer-events-none absolute -mt-[3.5px] -ml-[3.5px] size-[7px] rounded-full bg-foreground ring-2 ring-muted transition-[left,top,opacity,scale] duration-150 ease-out\",\n          at ? \"scale-100 opacity-100\" : \"scale-50 opacity-0\",\n        )}\n        style={{\n          left: `${((at?.x ?? SPARK_W) / SPARK_W) * 100}%`,\n          top: `${((at?.y ?? SPARK_H / 2) / SPARK_H) * 100}%`,\n        }}\n      />\n    </div>\n  );\n}\n\nconst currency = new Intl.NumberFormat(\"en-US\", {\n  style: \"currency\",\n  currency: \"USD\",\n  maximumFractionDigits: 0,\n});\nconst count = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 });\nconst rate = new Intl.NumberFormat(\"en-US\", {\n  style: \"percent\",\n  minimumFractionDigits: 2,\n  maximumFractionDigits: 2,\n});\nconst ms = new Intl.NumberFormat(\"en-US\", {\n  style: \"unit\",\n  unit: \"millisecond\",\n  unitDisplay: \"short\",\n  maximumFractionDigits: 0,\n});\n\n// Seeded, so the server and the browser agree on the first render.\nfunction mulberry32(seed: number) {\n  return () => {\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\nconst METRICS = [\n  { label: \"Revenue\", format: currency, base: 48210, swing: 0.08, goodWhen: \"up\" },\n  { label: \"Users\", format: count, base: 12480, swing: 0.06, goodWhen: \"up\" },\n  { label: \"Conversion\", format: rate, base: 0.0342, swing: 0.07, goodWhen: \"up\" },\n  { label: \"Latency\", format: ms, base: 184, swing: 0.1, goodWhen: \"down\" },\n] as const;\nconst POINTS = 12;\n\n// A random walk per metric. Each refresh drops the oldest point and adds a\n// new one, so the line reads as time moving on rather than a new chart.\nfunction initialSeries() {\n  const random = mulberry32(7);\n  return METRICS.map((m) => {\n    const out: number[] = [m.base];\n    for (let i = 1; i < POINTS; i++) out.unshift(out[0] * (1 + (random() - 0.5) * m.swing));\n    return out;\n  });\n}\n\nfunction toStats(series: number[][]): Stat[] {\n  return METRICS.map((m, i) => {\n    const s = series[i];\n    const value = s[s.length - 1];\n    return {\n      label: m.label,\n      value,\n      format: m.format,\n      trend: value / s[s.length - 2] - 1,\n      goodWhen: m.goodWhen,\n      series: s,\n    };\n  });\n}\n\nexport default function StatCounterDemo() {\n  const [series, setSeries] = useState(initialSeries);\n  const [turns, setTurns] = useState(0);\n  const random = useRef(mulberry32(99));\n\n  const refresh = () => {\n    setTurns((t) => t + 1);\n    setSeries((all) =>\n      all.map((s, i) => {\n        const last = s[s.length - 1];\n        return [...s.slice(1), last * (1 + (random.current() - 0.45) * METRICS[i].swing * 2)];\n      }),\n    );\n  };\n\n  return (\n    <div className=\"flex w-[600px] max-w-full flex-col gap-4\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <p className=\"text-[15px] font-medium text-foreground\">Overview</p>\n          <p className=\"text-sm text-muted-foreground\">Compared with the previous day</p>\n        </div>\n        <button\n          type=\"button\"\n          onClick={refresh}\n          className=\"flex h-9 touch-manipulation items-center gap-2 rounded-full bg-background px-4 text-sm font-medium text-foreground shadow-raised transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n        >\n          {/* Half a turn per press, so repeated presses keep spinning the\n              same way instead of snapping back. */}\n          <svg\n            aria-hidden\n            viewBox=\"0 0 16 16\"\n            className=\"size-4 transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n            style={{ transform: `rotate(${turns * 180}deg)` }}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M13.25 8a5.25 5.25 0 0 1-9.4 3.2M2.75 8a5.25 5.25 0 0 1 9.4-3.2\" />\n            <path d=\"M12.5 1.75v3h-3M3.5 14.25v-3h3\" />\n          </svg>\n          Refresh\n        </button>\n      </div>\n      <StatCounter stats={toStats(series)} />\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {turns > 0 ? `Updated ${turns} ${turns === 1 ? \"time\" : \"times\"}` : \"\"}\n      </p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/stat-counter"}