{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-ticker",
  "type": "registry:ui",
  "title": "Number Ticker",
  "description": "A count-up number that starts when it scrolls into view and eases to `value`. It does nothing by default: `startValue` defaults to `value`, so you get an honest static number — and an honest SSR render — until you pass a lower one to opt into the count.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/magicui/number-ticker.tsx",
      "target": "components/ui/magicui/number-ticker.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\nimport { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from \"react\"\n\n\n\n// @interlace/number-ticker v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/number-ticker\n// What changed since: https://ds.interlace.tools/c/number-ticker#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — NumberTicker\n *\n * A count-up number that starts when it scrolls into view and eases to\n * `value`. It does nothing by default: `startValue` defaults to `value`, so\n * you get an honest static number — and an honest SSR render — until you pass\n * a lower one to opt into the count.\n *\n * Our reimplementation of the Magic UI component of the same name, rebuilt on\n * `requestAnimationFrame` + an ease-out-expo curve instead of Framer Motion\n * springs, which drops `motion/react` from the bundle for this component.\n *\n * ## Anatomy\n *\n *   NumberTicker                     (span — tabular-nums, formatted via Intl)\n *\n * There is one element. The count writes `ref.current.textContent` directly\n * on each frame rather than re-rendering, so React never sees the intermediate\n * values.\n *\n * ## Motion\n *\n * JS-driven — an `IntersectionObserver` at `threshold: 0.1` arms it, then a\n * `requestAnimationFrame` loop runs it. Neither is reachable by the CSS\n * `prefers-reduced-motion` reset in `styles/preflight.css`, so the contract is\n * enforced in JS: `useReducedMotion()` makes one effect write the final\n * formatted value straight to the node, and makes the animation effect return\n * before it ever constructs the observer. Under `reduce` the number is simply\n * correct from the start, and no observer is attached.\n *\n * The count fires once per mount — `hasAnimated` latches, so a `value` that\n * changes later updates nothing until remount.\n *\n * ## Colour\n *\n * `text-foreground` — the same token body copy uses, which is what a stat\n * inline in a sentence should inherit. It was `text-black dark:text-white`, a\n * hand-rolled approximation of that token that stopped being true under any\n * theme whose foreground is not pure black/white (both shipped ones:\n * `--interlace-foreground` is `#0d0b09` light, `#f0ede9` dark). Override via\n * `className` when the surrounding surface is not the page background.\n *\n * ## Formatting\n *\n * `Intl.NumberFormat(\"en-US\", …)`, on every frame and on the SSR render, so\n * the string never changes shape mid-count. `notation` is forwarded straight\n * through and defaults to `\"standard\"` — Intl's own default — so nothing moves\n * for an existing caller.\n *\n * `notation=\"compact\"` exists because six-figure stats overflow a tile.\n * `128,400` is eight glyphs at `tabular-nums`; in a stat card at the 320px\n * floor that wraps or clips, and every consumer so far has re-patched the same\n * `Intl` option locally rather than reaching for a prop that did not exist.\n * `128K` is four. The count still animates through the compact form, which is\n * the point: the width is stable for the whole run instead of growing a digit\n * at a time.\n *\n * Pair it with `decimalPlaces`: compact + `decimalPlaces={0}` rounds `128400`\n * to `128K`, compact + `decimalPlaces={1}` gives `128.4K`.\n *\n * ## One API edge worth knowing\n *\n * Units are mixed: `duration` is milliseconds (default 1500) but `delay` is\n * seconds (`Date.now() + delay * 1000`).\n */\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\"\n\ninterface NumberTickerProps extends ComponentPropsWithoutRef<\"span\"> {\n  value: number\n  /**\n   * Where the count-up starts. Defaults to `value` — meaning **no animation**\n   * and an honest SSR render (UX_PHILOSOPHY §6: \"ease of use is performance\"\n   * — a stat that says `0` on first paint reads as broken). Pass an explicit\n   * lower number to opt into the count-up effect.\n   */\n  startValue?: number\n  direction?: \"up\" | \"down\"\n  delay?: number\n  decimalPlaces?: number\n  /**\n   * `Intl.NumberFormat` notation, forwarded verbatim.\n   *\n   * Defaults to `\"standard\"`, which is Intl's own default — so leaving it\n   * unset is byte-identical to the pre-prop behaviour. Use `\"compact\"` for\n   * six-figure metrics in a fixed-width tile (`128400` → `128K`), where the\n   * grouped form overflows at the 320px floor.\n   * @default \"standard\"\n   */\n  notation?: Intl.NumberFormatOptions[\"notation\"]\n  /** Duration of animation in ms (default: 1500) */\n  duration?: number\n}\n\n/**\n * NumberTicker - Performance Optimized\n *\n * Uses requestAnimationFrame + easeOutExpo instead of Framer Motion springs.\n * This reduces the JS bundle size and eliminates the motion/react dependency\n * for a simple counting animation.\n */\nexport function NumberTicker({\n  value,\n  startValue,\n  direction = \"up\",\n  delay = 0,\n  className,\n  decimalPlaces = 0,\n  notation = \"standard\",\n  duration = 1500,\n  ...props\n}: NumberTickerProps) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const [hasAnimated, setHasAnimated] = useState(false)\n  const reduceMotion = useReducedMotion()\n  const from = startValue ?? value\n  const shouldAnimate = from !== value\n\n  // Format number with locale (memoized to prevent useEffect recreation).\n  // `notation` joins the dep list — a formatter memoized on the old deps alone\n  // would keep the previous notation for the life of the mount.\n  const formatNumber = useCallback((num: number) =>\n    Intl.NumberFormat(\"en-US\", {\n      notation,\n      minimumFractionDigits: decimalPlaces,\n      maximumFractionDigits: decimalPlaces,\n    }).format(Number(num.toFixed(decimalPlaces))), [decimalPlaces, notation])\n\n  // Reduced-motion: jump straight to the final value — no easing, no observer.\n  useEffect(() => {\n    if (reduceMotion && ref.current) {\n      ref.current.textContent = formatNumber(value)\n    }\n  }, [reduceMotion, value, formatNumber])\n\n  useEffect(() => {\n    if (!ref.current || hasAnimated || reduceMotion || !shouldAnimate) return\n\n    // IntersectionObserver to trigger when in view\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting && !hasAnimated) {\n          setHasAnimated(true)\n\n          const startTime = Date.now() + delay * 1000\n          const animFrom = direction === \"down\" ? value : from\n          const to = direction === \"down\" ? from : value\n          \n          const animate = () => {\n            const now = Date.now()\n            if (now < startTime) {\n              requestAnimationFrame(animate)\n              return\n            }\n            \n            const elapsed = now - startTime\n            const progress = Math.min(elapsed / duration, 1)\n            \n            // Ease out expo for smooth deceleration\n            const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)\n            const current = animFrom + (to - animFrom) * eased\n            \n            if (ref.current) {\n              ref.current.textContent = formatNumber(current)\n            }\n            \n            if (progress < 1) {\n              requestAnimationFrame(animate)\n            }\n          }\n          \n          requestAnimationFrame(animate)\n        }\n      },\n      { threshold: 0.1 }\n    )\n\n    observer.observe(ref.current)\n    return () => observer.disconnect()\n  }, [value, from, direction, delay, duration, decimalPlaces, hasAnimated, formatNumber, reduceMotion, shouldAnimate])\n\n  return (\n    <span\n      ref={ref}\n      className={cn(\n        \"inline-block tracking-wider text-foreground tabular-nums\",\n        className\n      )}\n      {...props}\n    >\n      {formatNumber(from)}\n    </span>\n  )\n}\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/number-ticker\n\nInstalled to `components/ui/magicui/number-ticker.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/magicui/number-ticker';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/number-ticker\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
