{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-counter",
  "type": "registry:ui",
  "title": "Animated Counter",
  "description": "A locale-aware count-up number that plays once in view and never shifts the layout.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/animated-counter.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type CounterEasing = \"linear\" | \"easeOut\" | \"easeInOut\" | \"expo\";\n\nexport interface AnimatedCounterProps extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** The number to count up (or down) to. */\n  value: number;\n  /** Where the count starts on first play. */\n  from?: number;\n  /** Fixed number of fraction digits. */\n  decimals?: number;\n  /** Text rendered before the number, e.g. \"$\". */\n  prefix?: string;\n  /** Text rendered after the number, e.g. \"%\" or \"+\". */\n  suffix?: string;\n  /** Start when the counter scrolls into view (once) or immediately on mount. */\n  trigger?: \"view\" | \"mount\";\n  /** Animation length in milliseconds. */\n  duration?: number;\n  easing?: CounterEasing;\n  /** BCP 47 locale for grouping and decimal separators. */\n  locale?: string;\n  /** Use locale grouping separators (1,234 vs 1234). */\n  grouping?: boolean;\n}\n\nconst EASINGS: Record<CounterEasing, (t: number) => number> = {\n  linear: (t) => t,\n  easeOut: (t) => 1 - Math.pow(1 - t, 3),\n  easeInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),\n  expo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),\n};\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * A number that counts to its value once it is seen. Frames are written straight to the DOM\n * (no React re-render per frame), the final value is always available to assistive technology,\n * and an invisible copy of the start and end values reserves width so nothing shifts.\n */\nexport function AnimatedCounter({\n  value,\n  from = 0,\n  decimals = 0,\n  prefix = \"\",\n  suffix = \"\",\n  trigger = \"view\",\n  duration = 2000,\n  easing = \"easeOut\",\n  locale = \"en-US\",\n  grouping = true,\n  className = \"\",\n  ...props\n}: AnimatedCounterProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const numberRef = React.useRef<HTMLSpanElement>(null);\n  // The number currently on screen — new targets animate from here, never from zero.\n  const currentRef = React.useRef(from);\n  const [inView, setInView] = React.useState(false);\n\n  const formatter = React.useMemo(() => {\n    const digits = Math.max(0, Math.min(20, Math.round(decimals)));\n    try {\n      return new Intl.NumberFormat(locale, {\n        minimumFractionDigits: digits,\n        maximumFractionDigits: digits,\n        useGrouping: grouping,\n      });\n    } catch {\n      // Invalid locale strings fall back to the runtime default.\n      return new Intl.NumberFormat(undefined, {\n        minimumFractionDigits: digits,\n        maximumFractionDigits: digits,\n        useGrouping: grouping,\n      });\n    }\n  }, [locale, decimals, grouping]);\n\n  // Observe visibility once; the counter only ever plays forward.\n  React.useEffect(() => {\n    if (trigger !== \"view\" || inView) return;\n    const node = rootRef.current;\n    if (!node) return;\n    if (typeof IntersectionObserver === \"undefined\") {\n      const id = requestAnimationFrame(() => setInView(true));\n      return () => cancelAnimationFrame(id);\n    }\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.isIntersecting)) {\n          setInView(true);\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.4 },\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [trigger, inView]);\n\n  const active = trigger === \"mount\" || inView;\n\n  React.useEffect(() => {\n    const node = numberRef.current;\n    if (!node || !active) return;\n    const start = currentRef.current;\n    const end = value;\n    if (reduceMotion || duration <= 0 || start === end) {\n      currentRef.current = end;\n      node.textContent = formatter.format(end);\n      return;\n    }\n    const ease = EASINGS[easing] ?? EASINGS.easeOut;\n    let frame = 0;\n    let startedAt: number | null = null;\n    const tick = (now: number) => {\n      if (startedAt === null) startedAt = now;\n      const progress = Math.min(1, (now - startedAt) / duration);\n      const next = progress === 1 ? end : start + (end - start) * ease(progress);\n      currentRef.current = next;\n      node.textContent = formatter.format(next);\n      if (progress < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n    // `from` is listed so a re-render that resets the start text is immediately corrected.\n  }, [active, value, from, duration, easing, reduceMotion, formatter]);\n\n  const finalText = formatter.format(value);\n  const startText = formatter.format(from);\n\n  return (\n    <span\n      ref={rootRef}\n      className={`inline-flex items-baseline whitespace-nowrap tabular-nums ${className}`}\n      {...props}\n    >\n      {/* Visual layer: hidden from assistive technology, which reads the final value below. */}\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        {prefix ? <span>{prefix}</span> : null}\n        <span className=\"inline-grid justify-items-end\">\n          {/* Invisible copies reserve the widest of the start and end values. */}\n          <span className=\"invisible [grid-area:1/1]\">{finalText}</span>\n          <span className=\"invisible [grid-area:1/1]\">{startText}</span>\n          <span ref={numberRef} className=\"[grid-area:1/1]\" suppressHydrationWarning>\n            {startText}\n          </span>\n        </span>\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n      <span className=\"sr-only\">{`${prefix}${finalText}${suffix}`}</span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/animated-counter"
}
