{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-counter",
  "type": "registry:ui",
  "title": "Animated Counter",
  "description": "Rolling drum counter with spring physics, number grouping and prefix/suffix slots.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/animated-counter.tsx",
      "content": "\"use client\";\n\nimport { memo, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type Transition,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Grouping = \"western\" | \"indian\";\n\nexport interface AnimatedCounterProps\n  extends Omit<\n    ComponentProps<\"span\">,\n    | \"children\"\n    | \"prefix\"\n    | \"onAnimationStart\"\n    | \"onDrag\"\n    | \"onDragStart\"\n    | \"onDragEnd\"\n  > {\n  value: number;\n  decimals?: number;\n  duration?: number;\n  padStart?: number;\n  separator?: string;\n  decimalSeparator?: string;\n  grouping?: Grouping;\n  prefix?: ReactNode;\n  suffix?: ReactNode;\n  gooey?: boolean;\n}\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst BAND = 10;\nconst WHEEL = [...DIGITS, ...DIGITS, ...DIGITS];\nconst LINE_HEIGHT = 1.5;\n\nconst MASK_GRADIENT = `linear-gradient(to bottom,\n  rgba(0,0,0,0) 0%,\n  rgba(0,0,0,0.08) 6%,\n  rgba(0,0,0,0.6) 13%,\n  rgba(0,0,0,0.96) 20%,\n  #000 28%,\n  #000 72%,\n  rgba(0,0,0,0.96) 80%,\n  rgba(0,0,0,0.6) 87%,\n  rgba(0,0,0,0.08) 94%,\n  rgba(0,0,0,0) 100%)`;\n\nconst LEAVE_TRANSITION: Transition = {\n  duration: 0.18,\n  ease: [0.22, 1, 0.36, 1],\n};\n\nconst INSTANT_TRANSITION: Transition = {\n  duration: 0,\n};\n\nconst createSpring = (duration: number): Transition => ({\n  type: \"spring\",\n  visualDuration: duration,\n  bounce: 0.16,\n});\n\nconst SIZER_NODES = DIGITS.map((digit) => (\n  <span key={digit} aria-hidden className=\"invisible [grid-area:1/1]\">\n    {digit}\n  </span>\n));\n\nconst STACK_NODES = WHEEL.map((digit, index) => (\n  <span\n    key={index}\n    className=\"flex items-center justify-center select-none\"\n    style={{ height: `${LINE_HEIGHT}em` }}\n  >\n    {digit}\n  </span>\n));\n\nconst REGEX_WESTERN = /\\B(?=(\\d{3})+(?!\\d))/g;\nconst REGEX_INDIAN = /\\B(?=(\\d{2})+(?!\\d))/g;\n\nfunction formatInteger(whole: string, separator: string, grouping: Grouping) {\n  if (!separator) return whole;\n  if (grouping !== \"indian\") return whole.replace(REGEX_WESTERN, separator);\n\n  const head = whole.slice(0, -3);\n  if (!head) return whole;\n  return `${head.replace(REGEX_INDIAN, separator)}${separator}${whole.slice(-3)}`;\n}\n\ninterface Measurement {\n  amount: number;\n  scaled: number;\n  places: number;\n  duration: number;\n  width: number;\n}\n\nfunction computeMetrics(\n  value: number,\n  decimals: number,\n  padStart: number,\n  duration: number,\n): Measurement {\n  const amount = Number.isFinite(value) ? value : 0;\n  const places = Math.min(15, Math.max(0, Math.trunc(decimals)));\n  const pad = Math.min(24, Math.max(1, Math.trunc(padStart)));\n  const scaled = Math.min(\n    Number.MAX_SAFE_INTEGER,\n    Math.round(Math.abs(amount) * 10 ** places),\n  );\n\n  return {\n    amount,\n    scaled,\n    places,\n    duration: Math.min(60, Math.max(0.01, duration)),\n    width: Math.max(String(scaled).length, places + pad),\n  };\n}\n\nfunction stringifyValue(\n  metrics: Measurement,\n  separator: string,\n  decimalSeparator: string,\n  grouping: Grouping,\n) {\n  const raw = String(metrics.scaled).padStart(metrics.width, \"0\");\n  const whole = formatInteger(\n    raw.slice(0, raw.length - metrics.places) || \"0\",\n    separator,\n    grouping,\n  );\n  return metrics.places\n    ? `${whole}${decimalSeparator}${raw.slice(raw.length - metrics.places)}`\n    : whole;\n}\n\ntype CounterCell =\n  | { type: \"digit\"; key: number; digit: number }\n  | { type: \"mark\"; key: string; char: string };\n\nfunction buildCells(formatted: string, width: number): CounterCell[] {\n  const result: CounterCell[] = [];\n  let place = 0;\n  let markIndex = 0;\n\n  for (const char of formatted) {\n    if (char >= \"0\" && char <= \"9\") {\n      markIndex = 0;\n      result.push({\n        type: \"digit\",\n        key: width - place++,\n        digit: Number(char),\n      });\n    } else {\n      result.push({\n        type: \"mark\",\n        key: `mark-${width - place}-${markIndex++}`,\n        char,\n      });\n    }\n  }\n\n  return result;\n}\n\nfunction useContinuousWheel(\n  initialDigit: number,\n  targetDigit: number,\n  direction: number,\n  duration: number,\n  reduced: boolean,\n) {\n  const pos = useMotionValue(BAND + initialDigit);\n  const headingRef = useRef(direction);\n\n  useEffect(() => {\n    headingRef.current = direction;\n  }, [direction]);\n\n  useEffect(() => {\n    if (reduced) {\n      pos.set(BAND + targetDigit);\n      return;\n    }\n\n    const current = pos.get();\n    const currentFace = ((Math.round(current) % 10) + 10) % 10;\n    let diff = targetDigit - currentFace;\n\n    if (headingRef.current > 0 && diff <= 0) diff += 10;\n    if (headingRef.current < 0 && diff >= 0) diff -= 10;\n\n    const nextTarget = current + diff;\n    const animation = animate(pos, nextTarget, createSpring(duration));\n\n    return () => {\n      animation.stop();\n      pos.set(BAND + targetDigit);\n    };\n  }, [targetDigit, duration, reduced, pos]);\n\n  return useTransform(pos, (p) => `${(-p * 100) / WHEEL.length}%`);\n}\n\ninterface DigitSlotProps {\n  reduced: boolean;\n  dep: number;\n  transition: Transition;\n  filterId?: string;\n}\n\nconst DigitColumn = memo(function DigitColumn({\n  digit,\n  from,\n  direction,\n  duration,\n  slot,\n  ref,\n}: {\n  digit: number;\n  from: number;\n  direction: number;\n  duration: number;\n  slot: DigitSlotProps;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const y = useContinuousWheel(from, digit, direction, duration, slot.reduced);\n\n  return (\n    <motion.span\n      ref={ref}\n      data-slot=\"animated-counter-digit\"\n      layout={!slot.reduced}\n      layoutDependency={slot.dep}\n      transition={slot.transition}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{\n        opacity: 0,\n        transition: slot.reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n      }}\n      className=\"relative inline-grid overflow-hidden select-none\"\n      style={{\n        height: `${LINE_HEIGHT}em`,\n        lineHeight: LINE_HEIGHT,\n        maskImage: MASK_GRADIENT,\n        WebkitMaskImage: MASK_GRADIENT,\n        filter: slot.filterId ? `url(#${slot.filterId})` : undefined,\n      }}\n    >\n      {SIZER_NODES}\n      <motion.span style={{ y }} className=\"absolute inset-x-0 top-0\">\n        {STACK_NODES}\n      </motion.span>\n    </motion.span>\n  );\n});\n\nfunction GooeyDef({ id }: { id: string }) {\n  return (\n    <svg\n      className=\"absolute w-0 h-0 pointer-events-none opacity-0 overflow-hidden\"\n      aria-hidden=\"true\"\n      tabIndex={-1}\n    >\n      <defs>\n        <filter id={id} x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"0.65\" result=\"blur\" />\n          <feColorMatrix\n            in=\"blur\"\n            mode=\"matrix\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 16 -6\"\n            result=\"goo\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n\nexport function AnimatedCounter({\n  value,\n  decimals = 0,\n  duration = 0.6,\n  padStart = 1,\n  separator = \",\",\n  decimalSeparator = \".\",\n  grouping = \"western\",\n  prefix,\n  suffix,\n  gooey = true,\n  className,\n  ...props\n}: AnimatedCounterProps) {\n  const generatedId = useId().replace(/:/g, \"_\");\n  const filterId = `gooey_${generatedId}`;\n  const reduced = useReducedMotion() ?? false;\n\n  const metrics = computeMetrics(value, decimals, padStart, duration);\n  const formatted = stringifyValue(metrics, separator, decimalSeparator, grouping);\n  const cells = buildCells(formatted, metrics.width);\n  const isNegative = metrics.amount < 0 && metrics.scaled > 0;\n\n  const [previous, setPrevious] = useState(metrics.amount);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== metrics.amount) {\n    setDirection(metrics.amount >= previous ? 1 : -1);\n    setPrevious(metrics.amount);\n  }\n\n  const [seedFaces] = useState(() => {\n    const initial: Record<number, number> = {};\n    for (const cell of cells) {\n      if (cell.type === \"digit\") initial[cell.key] = cell.digit;\n    }\n    return initial;\n  });\n\n  const springTransition = useMemo<Transition>(\n    () => (reduced ? INSTANT_TRANSITION : createSpring(metrics.duration)),\n    [reduced, metrics.duration],\n  );\n\n  const slotProps: DigitSlotProps = {\n    reduced,\n    dep: formatted.length,\n    transition: springTransition,\n    filterId: gooey && !reduced ? filterId : undefined,\n  };\n\n  return (\n    <span\n      data-slot=\"animated-counter\"\n      className={cn(\"inline-flex items-center tabular-nums relative\", className)}\n      {...props}\n    >\n      {gooey && !reduced && <GooeyDef id={filterId} />}\n\n      {prefix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {prefix}\n        </motion.span>\n      )}\n\n      <span className=\"sr-only\">\n        {isNegative ? \"-\" : \"\"}\n        {formatted}\n      </span>\n\n      <span aria-hidden className=\"inline-flex select-none items-center\">\n        {isNegative && (\n          <motion.span\n            layout={!reduced}\n            layoutDependency={formatted.length}\n            transition={springTransition}\n            className=\"inline-block\"\n          >\n            -\n          </motion.span>\n        )}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {cells.map((cell) =>\n            cell.type === \"digit\" ? (\n              <DigitColumn\n                key={cell.key}\n                digit={cell.digit}\n                from={seedFaces[cell.key] ?? cell.digit}\n                direction={direction}\n                duration={metrics.duration}\n                slot={slotProps}\n              />\n            ) : (\n              <motion.span\n                key={cell.key}\n                data-slot=\"animated-counter-mark\"\n                layout={!reduced}\n                layoutDependency={formatted.length}\n                transition={springTransition}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{\n                  opacity: 0,\n                  transition: reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n                }}\n                className=\"inline-block\"\n              >\n                {cell.char}\n              </motion.span>\n            ),\n          )}\n        </AnimatePresence>\n      </span>\n\n      {suffix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {suffix}\n        </motion.span>\n      )}\n    </span>\n  );\n}\n\nexport default AnimatedCounter;\n",
      "type": "registry:ui",
      "target": "@ui/animated-counter.tsx"
    }
  ]
}
