{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"number-ticker","type":"registry:ui","title":"Number Ticker","description":"Counts to a target when it scrolls into view. The spring writes straight to textContent, so React never re-renders mid-count.","author":"@artbloom","dependencies":["clsx@2.1.1","motion@13.2.0","tailwind-merge@3.6.0"],"registryDependencies":[],"files":[{"path":"components/ui/number-ticker.tsx","target":"components/ui/number-ticker.tsx","content":"\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type NumberTickerProps = {\n  value: number\n  /** Count down from `value` to 0 instead of up to it. */\n  direction?: \"up\" | \"down\"\n  /** Seconds to wait after entering the viewport. */\n  delay?: number\n  decimals?: number\n  className?: string\n}\n\n/** Thousands separators, fixed decimals. One place, so a jump and a count agree. */\nfunction format(n: number, decimals: number) {\n  return n.toFixed(decimals).replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\")\n}\n\n/**\n * Counts to `value` the first time it scrolls into view. The spring runs on a\n * MotionValue and writes straight to textContent, so React never re-renders\n * during the count.\n *\n * THE SERVER RENDERS THE REAL NUMBER, NOT ZERO. It used to render `0` and only\n * reach `value` once JavaScript had run and the element had been scrolled to,\n * which meant the static HTML of every page using this component carried a figure\n * that was false — and that HTML is what a reader with JavaScript off, a crawler,\n * and every link-preview scraper actually see. A component whose entire job is to\n * display a number should not ship a wrong one as its markup. So the markup is the\n * final figure, and the count-up is arranged around it:\n *\n *  - Off screen at mount: rewound to the start with `jump()`, which sets the value\n *    without animating, and counted up when it scrolls in. Nobody sees the rewind\n *    because nobody is looking at it.\n *  - Already on screen at mount: left exactly as rendered. A reader looking at a\n *    correct number does not get to watch it reset itself to zero and climb back.\n *    Checked with `getBoundingClientRect`, because `useInView` reports `false` on\n *    the first render whether or not the element is visible — the observer has not\n *    fired yet — so it cannot answer this question.\n *\n * Under `prefers-reduced-motion` the spring is bypassed and the target is written\n * once, on mount. A count-up is a number moving, so the preference has to be\n * honoured by arriving rather than by travelling slower — and the figure the reader\n * came for is on screen either way.\n */\nexport function NumberTicker({\n  value,\n  direction = \"up\",\n  delay = 0,\n  decimals = 0,\n  className,\n}: NumberTickerProps) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const motionValue = useMotionValue(direction === \"down\" ? value : 0)\n  const spring = useSpring(motionValue, { damping: 60, stiffness: 100 })\n  const inView = useInView(ref, { once: true, margin: \"0px\" })\n  const reduced = useReducedMotion()\n  const start = direction === \"down\" ? value : 0\n  const target = direction === \"down\" ? 0 : value\n  /** Set when the number was already on screen, so the count is skipped entirely. */\n  const settled = useRef(false)\n\n  useEffect(() => {\n    if (reduced) return\n    const node = ref.current\n    if (!node) return\n\n    const rect = node.getBoundingClientRect()\n    if (rect.bottom > 0 && rect.top < window.innerHeight) {\n      settled.current = true\n      // Keep the MotionValue consistent with the text, so a later `set(target)`\n      // is a no-op rather than a jump back down.\n      motionValue.jump(target)\n      spring.jump(target)\n      return\n    }\n\n    motionValue.jump(start)\n    spring.jump(start)\n    // Mount only: this is about what was on screen when the page arrived.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    if (!inView || reduced || settled.current) return\n    const timer = setTimeout(() => {\n      motionValue.set(target)\n    }, delay * 1000)\n    return () => clearTimeout(timer)\n  }, [inView, reduced, delay, motionValue, target])\n\n  useEffect(() => {\n    if (reduced) {\n      if (ref.current) ref.current.textContent = format(target, decimals)\n      return\n    }\n    return spring.on(\"change\", (latest: number) => {\n      if (!ref.current) return\n      ref.current.textContent = format(latest, decimals)\n    })\n  }, [spring, decimals, reduced, target])\n\n  return (\n    <span ref={ref} className={cn(\"inline-block tabular-nums tracking-tight\", className)}>\n      {/* The resting figure for a count-up, the opening one for a count-down —\n          which is `value` either way, and formatted, so the first paint and the\n          last frame of the count use the same separators. */}\n      {format(direction === \"down\" ? value : target, decimals)}\n    </span>\n  )\n}\n","type":"registry:ui"},{"path":"lib/utils.ts","target":"lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n","type":"registry:lib"}],"meta":{"kind":"animations","categories":["numbers","text"],"docs":"https://ui.artbloom.tech/artbloom/animations/number-ticker"}}