{
  "name": "count-up",
  "type": "registry:ui",
  "title": "Count Up",
  "description": "Performant spring-physics numerical counter with decimals, prefix/suffix formatting, and scroll-triggered viewport detection.",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "registry/text/count-up/count-up.tsx",
      "type": "registry:ui",
      "target": "components/ui/count-up.tsx",
      "content": "import React, { useState, useEffect, useRef } from \"react\";\nimport { motion, useMotionValue, animate } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Helper function to format the number\nconst formatValue = (val: number, precision: number, sep: string): string => {\n  return val\n    .toFixed(precision)\n    .replace(/\\B(?=(\\d{3})+(?!\\d))/g, sep);\n};\n\nexport interface CountUpProps {\n  value: number;\n  duration?: number;\n  decimals?: number;\n  prefix?: string;\n  suffix?: string;\n  easing?: \"linear\" | \"easeIn\" | \"easeOut\" | \"easeInOut\";\n  separator?: string;\n  interactive?: boolean;\n  triggerOnView?: boolean;\n  className?: string;\n  numberClassName?: string;\n  animationStyle?: \"default\" | \"bounce\" | \"spring\" | \"gentle\" | \"energetic\";\n  colorScheme?: \"default\" | \"gradient\" | \"primary\" | \"secondary\" | \"custom\";\n  customColor?: string;\n  onAnimationComplete?: () => void;\n}\n\nconst easingFunctions = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n};\n\nconst animationStyles = {\n  default: { type: \"tween\" },\n  bounce: { type: \"spring\", bounce: 0.25 },\n  spring: { type: \"spring\", stiffness: 100, damping: 10 },\n  gentle: { type: \"spring\", stiffness: 60, damping: 15 },\n  energetic: { type: \"spring\", stiffness: 300, damping: 20 },\n};\n\nconst colorSchemes = {\n  default: \"text-zinc-900 dark:text-zinc-50\",\n  gradient: \"bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500\",\n  primary: \"text-indigo-600 dark:text-indigo-400\",\n  secondary: \"text-zinc-600 dark:text-zinc-400\",\n  custom: \"\",\n};\n\nexport function CountUp({\n  value,\n  duration = 2,\n  decimals = 0,\n  prefix = \"\",\n  suffix = \"\",\n  easing = \"easeOut\",\n  separator = \",\",\n  interactive = false,\n  triggerOnView = true,\n  className,\n  numberClassName,\n  animationStyle = \"default\",\n  colorScheme = \"default\",\n  customColor,\n  onAnimationComplete,\n}: CountUpProps) {\n  const [hasAnimated, setHasAnimated] = useState(false);\n  const [displayValue, setDisplayValue] = useState<string>(formatValue(0, decimals, separator));\n  const containerRef = useRef<HTMLDivElement>(null);\n  const count = useMotionValue(0);\n\n  const animationConfig = {\n    ...(animationStyles[animationStyle] as any),\n    ease: easingFunctions[easing],\n    duration: animationStyle === \"default\" ? duration : undefined,\n  };\n\n  useEffect(() => {\n    const unsubscribe = count.on(\"change\", (latest) => {\n      setDisplayValue(formatValue(latest, decimals, separator));\n    });\n    return () => unsubscribe();\n  }, [decimals, separator, count]);\n\n  useEffect(() => {\n    if (!triggerOnView) {\n      const controls = animate(count, value, {\n        ...animationConfig,\n        onComplete: () => {\n          setHasAnimated(true);\n          if (onAnimationComplete) onAnimationComplete();\n        },\n      });\n      return () => controls.stop();\n    }\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting && !hasAnimated) {\n          animate(count, value, {\n            ...animationConfig,\n            onComplete: () => {\n              setHasAnimated(true);\n              if (onAnimationComplete) onAnimationComplete();\n            },\n          });\n        }\n      },\n      { threshold: 0.1 }\n    );\n\n    if (containerRef.current) observer.observe(containerRef.current);\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [value, triggerOnView, hasAnimated]);\n\n  const colorClass =\n    colorScheme === \"custom\" && customColor ? \"\" : colorSchemes[colorScheme];\n\n  const getHoverAnimation = () => {\n    if (!interactive) return {};\n    return {\n      whileHover: {\n        scale: 1.05,\n        filter: \"brightness(1.1)\",\n        transition: { duration: 0.2 },\n      },\n      whileTap: {\n        scale: 0.95,\n        filter: \"brightness(0.95)\",\n        transition: { duration: 0.1 },\n      },\n    };\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"inline-flex items-center justify-center font-bold font-mono tracking-tight text-3xl sm:text-4xl text-zinc-900 dark:text-zinc-50\",\n        className\n      )}\n    >\n      <motion.div\n        {...getHoverAnimation()}\n        className={cn(\"flex items-center transition-all\", colorClass, numberClassName)}\n        style={colorScheme === \"custom\" && customColor ? { color: customColor } : undefined}\n      >\n        {prefix && <span className=\"mr-1\">{prefix}</span>}\n        <span>{displayValue}</span>\n        {suffix && <span className=\"ml-1\">{suffix}</span>}\n      </motion.div>\n    </div>\n  );\n}\n\nexport default CountUp;\n"
    }
  ],
  "$schema": "https://www.cooki-ui.design/schema/registry-item.json"
}