{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "count-up",
  "title": "Count Up",
  "description": "An animated number counter that springs to its target value when it enters the viewport, with support for decimals, separators, and direction.",
  "dependencies": [
    "motion",
    "react-use-measure"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/unlumen/count-up/index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport useMeasure from \"react-use-measure\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype DigitEffect = \"none\" | \"fade\" | \"blur\" | \"slide\";\n\ninterface CountUpProps {\n  to: number;\n  from?: number;\n  direction?: \"up\" | \"down\";\n  delay?: number;\n  duration?: number;\n  digitEffect?: DigitEffect;\n  className?: string;\n  startWhen?: boolean;\n  separator?: string;\n  onStart?: () => void;\n  onEnd?: () => void;\n}\n\ntype OdometerDigitProps = {\n  springValue: MotionValue<number>;\n  /** place value: 1, 10, 100, 1000 … */\n  place: number;\n};\n\nfunction OdometerDigit({ springValue, place }: OdometerDigitProps) {\n  const [ref, { height }] = useMeasure();\n\n  const y = useTransform(springValue, (v) => {\n    if (!height) return 0;\n    const digit = (Math.abs(v) / place) % 10;\n    return -digit * height;\n  });\n\n  return (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-block\",\n        width: \"1ch\",\n        overflowY: \"clip\",\n        overflowX: \"visible\",\n        lineHeight: 1,\n        fontVariantNumeric: \"tabular-nums\",\n      }}\n    >\n      <span ref={ref} style={{ visibility: \"hidden\", display: \"block\" }}>\n        0\n      </span>\n      <motion.span\n        style={{\n          y,\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          right: 0,\n          display: \"flex\",\n          flexDirection: \"column\",\n        }}\n      >\n        {/* 11 digits (0–9 + repeated 0) so the 9→0 wrap is seamless */}\n        {Array.from({ length: 11 }, (_, i) => (\n          <span\n            key={i}\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              height: height || \"1em\",\n            }}\n          >\n            {i % 10}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n\ntype CharSlotProps = {\n  char: string;\n  charKey: string;\n  effect: Exclude<DigitEffect, \"none\" | \"slide\">;\n  countingUp: boolean;\n};\n\nconst CHAR_VARIANTS = {\n  fade: {\n    initial: { opacity: 0, scale: 0.7 },\n    animate: { opacity: 1, scale: 1 },\n    exit: { opacity: 0, scale: 0.7 },\n    transition: { duration: 0.14, ease: \"easeOut\" },\n    overflow: \"hidden\" as const,\n  },\n  blur: {\n    initial: (up: boolean) => ({\n      opacity: 0,\n      filter: \"blur(8px)\",\n      y: up ? -8 : 8,\n    }),\n    animate: { opacity: 1, filter: \"blur(0px)\", y: 0 },\n    exit: (up: boolean) => ({\n      opacity: 0,\n      filter: \"blur(8px)\",\n      y: up ? 8 : -8,\n    }),\n    transition: { duration: 0.18, ease: \"easeOut\" },\n    overflow: \"visible\" as const,\n  },\n};\n\nfunction CharSlot({ char, charKey, effect, countingUp }: CharSlotProps) {\n  const isDigit = /\\d/.test(char);\n\n  if (!isDigit) {\n    return <span style={{ display: \"inline-block\" }}>{char}</span>;\n  }\n\n  const v = CHAR_VARIANTS[effect];\n  const initial =\n    typeof v.initial === \"function\" ? v.initial(countingUp) : v.initial;\n  const exit =\n    \"exit\" in v && typeof v.exit === \"function\"\n      ? (v.exit as (up: boolean) => object)(countingUp)\n      : (v.exit as object);\n\n  return (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-block\",\n        overflow: v.overflow,\n      }}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={charKey}\n          initial={initial}\n          animate={v.animate}\n          transition={v.transition as object}\n          style={{ display: \"inline-block\" }}\n        >\n          {char}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction CountUp({\n  to,\n  from = 0,\n  direction = \"up\",\n  delay = 0,\n  duration = 2,\n  digitEffect = \"none\",\n  className,\n  startWhen = true,\n  separator = \"\",\n  onStart,\n  onEnd,\n}: CountUpProps) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const motionValue = useMotionValue(direction === \"down\" ? to : from);\n\n  const damping = 20 + 40 * (1 / duration);\n  const stiffness = 100 * (1 / duration);\n\n  const springValue = useSpring(motionValue, { damping, stiffness });\n  const isInView = useInView(ref, { once: true, margin: \"0px\" });\n\n  const getDecimalPlaces = (num: number): number => {\n    const str = num.toString();\n    if (str.includes(\".\")) {\n      const parts = str.split(\".\");\n      const decimals = parts[1];\n      if (decimals && parseInt(decimals) !== 0) return decimals.length;\n    }\n    return 0;\n  };\n\n  const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));\n\n  const formatValue = React.useCallback(\n    (latest: number) => {\n      const hasDecimals = maxDecimals > 0;\n      const options: Intl.NumberFormatOptions = {\n        useGrouping: !!separator,\n        minimumFractionDigits: hasDecimals ? maxDecimals : 0,\n        maximumFractionDigits: hasDecimals ? maxDecimals : 0,\n      };\n      const formatted = Intl.NumberFormat(\"en-US\", options).format(latest);\n      return separator ? formatted.replace(/,/g, separator) : formatted;\n    },\n    [maxDecimals, separator],\n  );\n\n  const initialStr = formatValue(direction === \"down\" ? to : from);\n  const [chars, setChars] = React.useState<string[]>(initialStr.split(\"\"));\n\n  React.useEffect(() => {\n    const initial = formatValue(direction === \"down\" ? to : from);\n    if (digitEffect === \"none\") {\n      if (ref.current) ref.current.textContent = initial;\n    } else if (digitEffect !== \"slide\") {\n      setChars(initial.split(\"\"));\n    }\n  }, [from, to, direction, formatValue, digitEffect]);\n\n  React.useEffect(() => {\n    if (isInView && startWhen) {\n      onStart?.();\n      const t1 = setTimeout(() => {\n        motionValue.set(direction === \"down\" ? from : to);\n      }, delay * 1000);\n      const t2 = setTimeout(() => onEnd?.(), delay * 1000 + duration * 1000);\n      return () => {\n        clearTimeout(t1);\n        clearTimeout(t2);\n      };\n    }\n  }, [\n    isInView,\n    startWhen,\n    motionValue,\n    direction,\n    from,\n    to,\n    delay,\n    onStart,\n    onEnd,\n    duration,\n  ]);\n\n  React.useEffect(() => {\n    const unsubscribe = springValue.on(\"change\", (latest: number) => {\n      if (digitEffect === \"none\") {\n        if (ref.current) ref.current.textContent = formatValue(latest);\n      } else if (digitEffect !== \"slide\") {\n        setChars(formatValue(latest).split(\"\"));\n      }\n    });\n    return () => unsubscribe();\n  }, [springValue, formatValue, digitEffect]);\n\n  const countingUp = direction === \"up\";\n\n  if (digitEffect === \"slide\") {\n    const targetStr = formatValue(direction === \"down\" ? from : to);\n    const digits: number[] = [];\n    const structure: Array<{\n      type: \"digit\" | \"sep\";\n      char?: string;\n      placeIdx?: number;\n    }> = [];\n    let digitCount = 0;\n    for (const ch of targetStr) {\n      if (/\\d/.test(ch)) digitCount++;\n    }\n    let d = 0;\n    for (const ch of targetStr) {\n      if (/\\d/.test(ch)) {\n        const placeFromRight = digitCount - 1 - d;\n        structure.push({ type: \"digit\", placeIdx: placeFromRight });\n        d++;\n      } else {\n        structure.push({ type: \"sep\", char: ch });\n      }\n    }\n\n    return (\n      <span\n        ref={ref}\n        className={cn(\"inline-flex items-center\", className)}\n        style={{ fontVariantNumeric: \"tabular-nums\" }}\n      >\n        {structure.map((item, i) =>\n          item.type === \"sep\" ? (\n            <span key={i}>{item.char}</span>\n          ) : (\n            <OdometerDigit\n              key={i}\n              springValue={springValue}\n              place={Math.pow(10, item.placeIdx!)}\n            />\n          ),\n        )}\n      </span>\n    );\n  }\n\n  if (digitEffect === \"none\") {\n    return <span ref={ref} className={cn(className)} />;\n  }\n\n  return (\n    <span ref={ref} className={cn(\"inline-flex items-center\", className)}>\n      {chars.map((char, i) => (\n        <CharSlot\n          key={i}\n          char={char}\n          charKey={`${i}-${char}`}\n          effect={digitEffect as Exclude<DigitEffect, \"none\" | \"slide\">}\n          countingUp={countingUp}\n        />\n      ))}\n    </span>\n  );\n}\n\nexport { CountUp, type CountUpProps, type DigitEffect };\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/count-up.tsx"
    }
  ],
  "type": "registry:ui"
}