{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-ticker","type":"registry:ui","title":"NumberTicker","description":"Animated number counter with easing, triggered on viewport entry","categories":["text"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/number-ticker/NumberTicker.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { cn } from \"../../utils\";\n\n/**\n * NumberTicker - Animated number counter\n *\n * Animates a number from 0 to `value` (or vice versa) with easing.\n * Triggers when the element enters the viewport via IntersectionObserver.\n * Formatted using Intl.NumberFormat.\n */\nexport interface NumberTickerProps {\n\t/** Target number to animate to */\n\tvalue?: number;\n\t/** Animation direction: \"up\" counts 0→value, \"down\" counts value→0 */\n\tdirection?: \"up\" | \"down\";\n\t/** Animation duration in ms */\n\tduration?: number;\n\t/** Delay before animation starts (ms) */\n\tdelay?: number;\n\t/** Number of decimal places to display */\n\tdecimalPlaces?: number;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n// Easing: easeOutCubic\nfunction easeOutCubic(t: number): number {\n\treturn 1 - Math.pow(1 - t, 3);\n}\n\nexport function NumberTicker({\n\tvalue = 0,\n\tdirection = \"up\",\n\tduration = 1000,\n\tdelay = 0,\n\tdecimalPlaces = 0,\n\tclassName,\n}: NumberTickerProps) {\n\tconst spanRef = useRef<HTMLSpanElement | null>(null);\n\tconst [displayValue, setDisplayValue] = useState(0);\n\tconst displayValueRef = useRef(0);\n\tconst hasAnimatedRef = useRef(false);\n\tconst animationFrameIdRef = useRef<number | null>(null);\n\n\tconst initialValue = direction === \"down\" ? value : 0;\n\n\tfunction setDisplay(next: number) {\n\t\tdisplayValueRef.current = next;\n\t\tsetDisplayValue(next);\n\t}\n\n\t// Identity-stable, so the []-deps mount effect and the re-animate effect always\n\t// run the latest render's closure without being rebuilt.\n\tconst animate = useEventCallback((target: number) => {\n\t\t// One chain per instance: a target change mid-count cancels the frame still\n\t\t// in flight, so two interpolations never write `displayValue` on alternating\n\t\t// frames and the unmount cleanup always has the only live id to cancel.\n\t\tif (animationFrameIdRef.current !== null) {\n\t\t\tcancelAnimationFrame(animationFrameIdRef.current);\n\t\t\tanimationFrameIdRef.current = null;\n\t\t}\n\n\t\tconst start = displayValueRef.current;\n\t\tconst startTime = performance.now() + delay;\n\n\t\tfunction tick(now: number) {\n\t\t\tif (now < startTime) {\n\t\t\t\tanimationFrameIdRef.current = requestAnimationFrame(tick);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst elapsed = now - startTime;\n\t\t\tconst progress = Math.min(elapsed / duration, 1);\n\t\t\tconst easedProgress = easeOutCubic(progress);\n\n\t\t\tsetDisplay(start + (target - start) * easedProgress);\n\n\t\t\tif (progress < 1) {\n\t\t\t\tanimationFrameIdRef.current = requestAnimationFrame(tick);\n\t\t\t} else {\n\t\t\t\tanimationFrameIdRef.current = null;\n\t\t\t}\n\t\t}\n\n\t\tanimationFrameIdRef.current = requestAnimationFrame(tick);\n\t});\n\n\t// Live prop mirrors, so the observer callback reads the latest value/direction\n\t// exactly as the Svelte onMount closure does.\n\tconst valueRef = useLiveRef(value);\n\tconst directionRef = useLiveRef(direction);\n\n\t// Set initial display value\n\tuseEffect(() => {\n\t\tif (!hasAnimatedRef.current) {\n\t\t\tsetDisplay(initialValue);\n\t\t}\n\t}, [initialValue]);\n\n\tuseEffect(() => {\n\t\tconst node = spanRef.current;\n\t\tif (!node) return;\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tif (entries[0]?.isIntersecting && !hasAnimatedRef.current) {\n\t\t\t\t\thasAnimatedRef.current = true;\n\t\t\t\t\tconst target = directionRef.current === \"down\" ? 0 : valueRef.current;\n\t\t\t\t\tanimate(target);\n\t\t\t\t\tobserver.disconnect();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold: 0 }\n\t\t);\n\n\t\tobserver.observe(node);\n\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t\tif (animationFrameIdRef.current !== null) {\n\t\t\t\tcancelAnimationFrame(animationFrameIdRef.current);\n\t\t\t\tanimationFrameIdRef.current = null;\n\t\t\t}\n\t\t};\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only observer; `animate` is identity-stable\n\t}, []);\n\n\t// Re-animate when value prop changes after initial animation\n\tconst animTarget = direction === \"down\" ? 0 : value;\n\tuseEffect(() => {\n\t\tif (hasAnimatedRef.current) {\n\t\t\tanimate(animTarget);\n\t\t}\n\t}, [animTarget, animate]);\n\n\tconst formattedValue = new Intl.NumberFormat(\"en-US\", {\n\t\tminimumFractionDigits: decimalPlaces,\n\t\tmaximumFractionDigits: decimalPlaces,\n\t}).format(Number(displayValue.toFixed(decimalPlaces)));\n\n\treturn (\n\t\t<span\n\t\t\tref={spanRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"number-ticker inline-block text-black tabular-nums dark:text-white\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t>\n\t\t\t{formattedValue}\n\t\t</span>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/number-ticker/NumberTicker.tsx"},{"path":"react/src/components/number-ticker/index.ts","content":"\"use client\";\n\nexport { NumberTicker } from \"./NumberTicker\";\nexport type { NumberTickerProps } from \"./NumberTicker\";\n","type":"registry:ui","target":"@ui/fancy/components/number-ticker/index.ts"}]}