{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rolling-digits",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "rolling-digits.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useInView, useSpring } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type RollingDigitsLocale = true | string | Intl.NumberFormatOptions;\n\nexport interface RollingDigitsProps {\n  /** Target number to display. Rounded to the nearest integer before formatting. */\n  value: number;\n  /** Minimum digit count before locale separators are applied. */\n  pad?: number;\n  /**\n   * When true, waits until the component enters the viewport once before\n   * animating from zero.\n   * @default true\n   */\n  startOnView?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /**\n   * Locale formatting. `true` uses the runtime default locale, a string sets\n   * the locale tag, and an object is passed to `Intl.NumberFormat`.\n   */\n  locale?: RollingDigitsLocale;\n  /** Custom formatter. Runs after rounding and overrides `locale`. */\n  format?: (value: number) => string;\n  /** Pixel gap between rendered characters. @default 2 */\n  gap?: number;\n  /**\n   * Milliseconds between queued value steps when `value` changes faster than\n   * the animation can finish.\n   * @default 80\n   */\n  animationDelay?: number;\n  /** @deprecated Use `animationDelay` instead. Seconds between queued steps. */\n  stagger?: number;\n  /** Spring stiffness for incoming digit motion. @default 170 */\n  enterStiffness?: number;\n  /** Spring damping for incoming digit motion. @default 10 */\n  enterDamping?: number;\n  /** Spring stiffness for outgoing digit motion. @default 170 */\n  exitStiffness?: number;\n  /** Spring damping for outgoing digit motion. @default 15 */\n  exitDamping?: number;\n  /**\n   * Controls whether incoming digits slide up or down.\n   * @default \"dynamic\"\n   */\n  direction?: \"dynamic\" | \"up\" | \"down\";\n  /** Vertical offset in pixels used when a digit enters. @default 32 */\n  enterY?: number;\n  /** @deprecated Blur is no longer applied. Kept for backward compatibility. */\n  enterBlur?: number;\n  /** Starting scale applied when a digit enters. @default 0.84 */\n  enterScale?: number;\n  /** Ending scale applied when a digit exits. @default 0.84 */\n  exitScale?: number;\n  /**\n   * When true, rapid `value` updates replace the pending queue with the latest\n   * value instead of stepping through every intermediate update.\n   */\n  coalesceUpdates?: boolean;\n  /** Called when the displayed value catches up to the latest `value`. */\n  onAnimationComplete?: () => void;\n  /**\n   * Adds `aria-live` to the screen-reader layer. `true` maps to `\"polite\"`.\n   * @default true\n   */\n  ariaLive?: boolean | \"polite\" | \"assertive\" | \"off\";\n}\n\nexport interface RollingDigitsTextProps {\n  value: string;\n  gap?: number;\n  className?: string;\n  digitClassName?: string;\n  enterStiffness?: number;\n  enterDamping?: number;\n  exitStiffness?: number;\n  exitDamping?: number;\n  direction?: \"dynamic\" | \"up\" | \"down\";\n  enterY?: number;\n  /** @deprecated Blur is no longer applied. Kept for backward compatibility. */\n  enterBlur?: number;\n  enterScale?: number;\n  exitScale?: number;\n  animationDelay?: number;\n  coalesceUpdates?: boolean;\n  onAnimationComplete?: () => void;\n}\n\nexport interface RollingDigitCellDescriptor {\n  key: string;\n  char: string;\n  isDigit: boolean;\n}\n\ninterface ExitItem {\n  id: number;\n  char: string;\n  exitY: number;\n}\n\nlet exitId = 0;\nconst DIGIT_PATTERN = /\\d/;\n\nconst DIGIT_MOTION_DEFAULTS = {\n  enterStiffness: 170,\n  enterDamping: 10,\n  exitStiffness: 170,\n  exitDamping: 15,\n  direction: \"dynamic\" as const,\n  enterY: 32,\n  enterScale: 0.84,\n  exitScale: 0.84,\n};\n\nfunction isDigitChar(char: string) {\n  return DIGIT_PATTERN.test(char);\n}\n\nfunction normalizeRollingDigitsValue(value: number) {\n  if (!Number.isFinite(value)) {\n    return 0;\n  }\n\n  return Math.round(value);\n}\n\nfunction normalizePad(pad?: number) {\n  if (pad === undefined) {\n    return undefined;\n  }\n\n  const normalized = Math.max(0, Math.floor(pad));\n  return normalized > 0 ? normalized : undefined;\n}\n\nfunction safeFormattedNumber(rounded: number, formatted: string) {\n  return formatted.length > 0 ? formatted : rounded.toString();\n}\n\nfunction formatWithLocale(\n  rounded: number,\n  locale: RollingDigitsLocale\n): string {\n  try {\n    if (locale === true) {\n      return rounded.toLocaleString();\n    }\n\n    if (typeof locale === \"string\") {\n      return rounded.toLocaleString(locale);\n    }\n\n    return rounded.toLocaleString(undefined, locale);\n  } catch {\n    return rounded.toString();\n  }\n}\n\nfunction formatWithCustomFormatter(\n  rounded: number,\n  format: (value: number) => string\n) {\n  try {\n    return safeFormattedNumber(rounded, format(rounded));\n  } catch {\n    return rounded.toString();\n  }\n}\n\nfunction formatPaddedNumber(rounded: number, normalizedPad: number) {\n  const sign = rounded < 0 ? \"-\" : \"\";\n  return sign + Math.abs(rounded).toString().padStart(normalizedPad, \"0\");\n}\n\nfunction formatLocaleValue(\n  rounded: number,\n  locale: RollingDigitsLocale,\n  normalizedPad?: number\n) {\n  if (typeof locale === \"object\") {\n    const options: Intl.NumberFormatOptions = { ...locale };\n\n    if (normalizedPad) {\n      options.minimumIntegerDigits = normalizedPad;\n    }\n\n    return formatWithLocale(rounded, options);\n  }\n\n  if (normalizedPad) {\n    try {\n      return rounded.toLocaleString(undefined, {\n        minimumIntegerDigits: normalizedPad,\n      });\n    } catch {\n      return formatPaddedNumber(rounded, normalizedPad);\n    }\n  }\n\n  return formatWithLocale(rounded, locale);\n}\n\nexport function formatRollingDigitsValue(\n  value: number,\n  { pad, locale, format }: Pick<RollingDigitsProps, \"pad\" | \"locale\" | \"format\">\n) {\n  const rounded = normalizeRollingDigitsValue(value);\n  const normalizedPad = normalizePad(pad);\n\n  if (format) {\n    return formatWithCustomFormatter(rounded, format);\n  }\n\n  if (locale) {\n    return formatLocaleValue(rounded, locale, normalizedPad);\n  }\n\n  if (normalizedPad) {\n    return formatPaddedNumber(rounded, normalizedPad);\n  }\n\n  return rounded.toString();\n}\n\nexport function buildRollingDigitCells(\n  value: string\n): RollingDigitCellDescriptor[] {\n  if (!value) {\n    return [{ key: \"digit-r0\", char: \"0\", isDigit: true }];\n  }\n\n  const chars = value.split(\"\");\n  const digitIndexFromRight = new Array<number>(chars.length).fill(-1);\n  let fromRight = 0;\n\n  for (let index = chars.length - 1; index >= 0; index -= 1) {\n    if (isDigitChar(chars[index])) {\n      digitIndexFromRight[index] = fromRight;\n      fromRight += 1;\n    }\n  }\n\n  return chars.map((char, index) => {\n    const isDigit = isDigitChar(char);\n\n    if (isDigit) {\n      return {\n        key: `digit-r${digitIndexFromRight[index]}`,\n        char,\n        isDigit,\n      };\n    }\n\n    let key = `sep-l${index}`;\n\n    for (let nextIndex = index + 1; nextIndex < chars.length; nextIndex += 1) {\n      if (!isDigitChar(chars[nextIndex])) {\n        continue;\n      }\n\n      let hasDigitLeft = false;\n\n      for (let leftIndex = index - 1; leftIndex >= 0; leftIndex -= 1) {\n        if (isDigitChar(chars[leftIndex])) {\n          hasDigitLeft = true;\n          break;\n        }\n      }\n\n      if (hasDigitLeft) {\n        key = `sep-r${digitIndexFromRight[nextIndex]}`;\n      }\n\n      break;\n    }\n\n    return { key, char, isDigit };\n  });\n}\n\nfunction serializeRollingDigitsLocale(locale?: RollingDigitsLocale) {\n  if (!locale) {\n    return \"none\";\n  }\n\n  if (locale === true) {\n    return \"default\";\n  }\n\n  if (typeof locale === \"string\") {\n    return locale;\n  }\n\n  return JSON.stringify(\n    locale,\n    Object.keys(locale as Record<string, unknown>).sort()\n  );\n}\n\nfunction getRollingDigitsFormattingKey({\n  pad,\n  locale,\n  format,\n}: Pick<RollingDigitsProps, \"pad\" | \"locale\" | \"format\">) {\n  return `${normalizePad(pad) ?? \"none\"}:${serializeRollingDigitsLocale(locale)}:${format ? \"format\" : \"plain\"}`;\n}\n\nfunction resolveAriaLive(\n  ariaLive: RollingDigitsProps[\"ariaLive\"]\n): \"polite\" | \"assertive\" | undefined {\n  if (ariaLive === false || ariaLive === \"off\") {\n    return undefined;\n  }\n\n  if (ariaLive === true || ariaLive === undefined) {\n    return \"polite\";\n  }\n\n  return ariaLive;\n}\n\nfunction resolveAnimationDelay(animationDelay?: number, stagger?: number) {\n  if (animationDelay !== undefined) {\n    return Math.max(0, animationDelay);\n  }\n\n  if (stagger !== undefined) {\n    return Math.max(0, Math.round(stagger * 1000));\n  }\n\n  return 80;\n}\n\nfunction DigitCell({\n  char,\n  isDigit,\n  className,\n  enterStiffness = DIGIT_MOTION_DEFAULTS.enterStiffness,\n  enterDamping = DIGIT_MOTION_DEFAULTS.enterDamping,\n  exitStiffness = DIGIT_MOTION_DEFAULTS.exitStiffness,\n  exitDamping = DIGIT_MOTION_DEFAULTS.exitDamping,\n  direction = DIGIT_MOTION_DEFAULTS.direction,\n  enterY = DIGIT_MOTION_DEFAULTS.enterY,\n  enterScale = DIGIT_MOTION_DEFAULTS.enterScale,\n  exitScale = DIGIT_MOTION_DEFAULTS.exitScale,\n}: {\n  char: string;\n  isDigit: boolean;\n  className?: string;\n  enterStiffness?: number;\n  enterDamping?: number;\n  exitStiffness?: number;\n  exitDamping?: number;\n  direction?: \"dynamic\" | \"up\" | \"down\";\n  enterY?: number;\n  enterBlur?: number;\n  enterScale?: number;\n  exitScale?: number;\n}) {\n  const [exitQueue, setExitQueue] = useState<ExitItem[]>([]);\n  const prevCharRef = useRef(char);\n  const isFirstRender = useRef(true);\n  const isMountedRef = useRef(true);\n\n  const springConfig = { stiffness: enterStiffness, damping: enterDamping };\n  const y = useSpring(0, springConfig);\n  const opacity = useSpring(1, springConfig);\n  const scale = useSpring(1, springConfig);\n\n  useEffect(() => {\n    isMountedRef.current = true;\n\n    return () => {\n      isMountedRef.current = false;\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!isDigit) {\n      return;\n    }\n\n    return () => {\n      setExitQueue([]);\n    };\n  }, [isDigit]);\n\n  useEffect(() => {\n    if (!isDigit) return;\n\n    const prev = prevCharRef.current;\n    prevCharRef.current = char;\n\n    if (isFirstRender.current) {\n      isFirstRender.current = false;\n      return;\n    }\n\n    if (char === prev || !isDigitChar(prev)) return;\n\n    const up =\n      direction === \"dynamic\"\n        ? Number(char) > Number(prev)\n        : direction === \"up\";\n\n    const id = exitId++;\n    setExitQueue((queue) => [\n      ...queue,\n      { id, char: prev, exitY: up ? -enterY : enterY },\n    ]);\n\n    y.jump(up ? enterY : -enterY);\n    opacity.jump(0);\n    scale.jump(enterScale);\n\n    y.set(0);\n    opacity.set(1);\n    scale.set(1);\n  }, [char, direction, enterScale, enterY, isDigit, opacity, scale, y]);\n\n  if (!isDigit) {\n    return <span className={className}>{char}</span>;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate grid min-h-[1em] min-w-[1ch] place-items-center overflow-hidden leading-none [&>*]:col-start-1 [&>*]:row-start-1\",\n        className\n      )}\n    >\n      <AnimatePresence>\n        {exitQueue.map(({ id, char: exitChar, exitY }) => (\n          <motion.span\n            animate={{\n              opacity: 0,\n              scale: exitScale,\n              y: exitY,\n            }}\n            aria-hidden\n            className=\"[backface-visibility:hidden]\"\n            initial={{ opacity: 1, scale: 1, y: 0 }}\n            key={id}\n            onAnimationComplete={() => {\n              if (!isMountedRef.current) return;\n\n              setExitQueue((queue) => queue.filter((item) => item.id !== id));\n            }}\n            transition={{\n              type: \"spring\",\n              stiffness: exitStiffness,\n              damping: exitDamping,\n            }}\n          >\n            {exitChar}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n      <motion.span\n        className=\"[backface-visibility:hidden]\"\n        style={{ opacity, scale, y }}\n      >\n        {char}\n      </motion.span>\n    </div>\n  );\n}\n\nexport function RollingDigitsText({\n  value,\n  gap = 2,\n  className,\n  digitClassName,\n  enterStiffness,\n  enterDamping,\n  exitStiffness,\n  exitDamping,\n  direction,\n  enterY,\n  enterScale,\n  exitScale,\n  animationDelay = 80,\n  coalesceUpdates = false,\n  onAnimationComplete,\n}: RollingDigitsTextProps) {\n  const [displayedValue, setDisplayedValue] = useState(value);\n  const pendingQueue = useRef<string[]>([]);\n  const isAnimating = useRef(false);\n  const hasPendingUpdateRef = useRef(false);\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const displayedRef = useRef(value);\n  const targetValueRef = useRef(value);\n  const animationDelayRef = useRef(animationDelay);\n  const onCompleteRef = useRef(onAnimationComplete);\n  const isMountedRef = useRef(true);\n\n  animationDelayRef.current = animationDelay;\n  onCompleteRef.current = onAnimationComplete;\n  targetValueRef.current = value;\n\n  const motionConfigKey = useMemo(\n    () =>\n      [\n        animationDelay,\n        coalesceUpdates,\n        direction,\n        enterDamping,\n        enterScale,\n        enterStiffness,\n        enterY,\n        exitDamping,\n        exitScale,\n        exitStiffness,\n        gap,\n      ].join(\":\"),\n    [\n      animationDelay,\n      coalesceUpdates,\n      direction,\n      enterDamping,\n      enterScale,\n      enterStiffness,\n      enterY,\n      exitDamping,\n      exitScale,\n      exitStiffness,\n      gap,\n    ]\n  );\n\n  const notifyComplete = useCallback(() => {\n    if (\n      !hasPendingUpdateRef.current ||\n      displayedRef.current !== targetValueRef.current ||\n      isAnimating.current ||\n      pendingQueue.current.length > 0\n    ) {\n      return;\n    }\n\n    hasPendingUpdateRef.current = false;\n    onCompleteRef.current?.();\n  }, []);\n\n  const processQueue = useCallback(() => {\n    if (!isMountedRef.current) {\n      return;\n    }\n\n    if (pendingQueue.current.length === 0) {\n      isAnimating.current = false;\n      notifyComplete();\n      return;\n    }\n\n    isAnimating.current = true;\n    const next = pendingQueue.current.shift();\n\n    if (next === undefined) {\n      isAnimating.current = false;\n      notifyComplete();\n      return;\n    }\n\n    displayedRef.current = next;\n    setDisplayedValue(next);\n\n    timerRef.current = setTimeout(processQueue, animationDelayRef.current);\n  }, [notifyComplete]);\n\n  useEffect(() => {\n    isMountedRef.current = true;\n\n    return () => {\n      isMountedRef.current = false;\n\n      if (timerRef.current) {\n        clearTimeout(timerRef.current);\n        timerRef.current = null;\n      }\n    };\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: motion settings changes must reset the queue without re-running on value updates.\n  useEffect(() => {\n    displayedRef.current = targetValueRef.current;\n    setDisplayedValue(targetValueRef.current);\n    pendingQueue.current = [];\n    isAnimating.current = false;\n    hasPendingUpdateRef.current = false;\n\n    if (timerRef.current) {\n      clearTimeout(timerRef.current);\n      timerRef.current = null;\n    }\n  }, [motionConfigKey]);\n\n  useEffect(() => {\n    if (value === displayedRef.current) {\n      notifyComplete();\n      return;\n    }\n\n    hasPendingUpdateRef.current = true;\n\n    if (coalesceUpdates) {\n      pendingQueue.current = [value];\n\n      if (timerRef.current) {\n        clearTimeout(timerRef.current);\n        timerRef.current = null;\n      }\n\n      isAnimating.current = false;\n      processQueue();\n      return;\n    }\n\n    pendingQueue.current.push(value);\n\n    if (!isAnimating.current) {\n      processQueue();\n    }\n  }, [coalesceUpdates, notifyComplete, processQueue, value]);\n\n  const cells = useMemo(\n    () => buildRollingDigitCells(displayedValue),\n    [displayedValue]\n  );\n\n  return (\n    <div\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n      style={{ gap }}\n    >\n      <AnimatePresence initial={false}>\n        {cells.map(({ key, char, isDigit }) => (\n          <motion.span\n            className=\"inline-flex\"\n            exit={\n              isDigit\n                ? {\n                    opacity: 0,\n                    y:\n                      direction === \"down\"\n                        ? (enterY ?? DIGIT_MOTION_DEFAULTS.enterY)\n                        : -(enterY ?? DIGIT_MOTION_DEFAULTS.enterY),\n                  }\n                : { opacity: 0 }\n            }\n            key={key}\n            transition={{\n              type: \"spring\",\n              stiffness: exitStiffness ?? DIGIT_MOTION_DEFAULTS.exitStiffness,\n              damping: exitDamping ?? DIGIT_MOTION_DEFAULTS.exitDamping,\n            }}\n          >\n            <DigitCell\n              char={char}\n              className={digitClassName}\n              direction={direction}\n              enterDamping={enterDamping}\n              enterScale={enterScale}\n              enterStiffness={enterStiffness}\n              enterY={enterY}\n              exitDamping={exitDamping}\n              exitScale={exitScale}\n              exitStiffness={exitStiffness}\n              isDigit={isDigit}\n            />\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport function RollingDigits({\n  value,\n  pad,\n  startOnView = true,\n  className,\n  digitClassName,\n  locale,\n  format,\n  gap,\n  animationDelay,\n  stagger,\n  enterStiffness,\n  enterDamping,\n  exitStiffness,\n  exitDamping,\n  direction,\n  enterY,\n  enterScale,\n  exitScale,\n  coalesceUpdates,\n  onAnimationComplete,\n  ariaLive = true,\n}: RollingDigitsProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) {\n      setArmed(true);\n    }\n  }, [startOnView, inView]);\n\n  useEffect(() => {\n    if (!startOnView) {\n      setArmed(true);\n    }\n  }, [startOnView]);\n\n  const formatOptions = useMemo(\n    () => ({ pad, locale, format }),\n    [format, locale, pad]\n  );\n  const text = useMemo(\n    () => formatRollingDigitsValue(value, formatOptions),\n    [formatOptions, value]\n  );\n  const zeroText = useMemo(\n    () => formatRollingDigitsValue(0, formatOptions),\n    [formatOptions]\n  );\n  const displayText = armed ? text : zeroText;\n  const resolvedAnimationDelay = resolveAnimationDelay(animationDelay, stagger);\n  const resolvedAriaLive = resolveAriaLive(ariaLive);\n  const formattingKey = useMemo(\n    () => getRollingDigitsFormattingKey({ pad, locale, format }),\n    [format, locale, pad]\n  );\n\n  return (\n    <span\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n      ref={containerRef}\n    >\n      <span aria-live={resolvedAriaLive} className=\"sr-only\">\n        {displayText}\n      </span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        <RollingDigitsText\n          animationDelay={resolvedAnimationDelay}\n          coalesceUpdates={coalesceUpdates}\n          digitClassName={digitClassName}\n          direction={direction}\n          enterDamping={enterDamping}\n          enterScale={enterScale}\n          enterStiffness={enterStiffness}\n          enterY={enterY}\n          exitDamping={exitDamping}\n          exitScale={exitScale}\n          exitStiffness={exitStiffness}\n          gap={gap}\n          key={formattingKey}\n          onAnimationComplete={onAnimationComplete}\n          value={displayText}\n        />\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "title": "Rolling Digits",
  "description": "Spring-animated digit counter with transform, opacity, and vertical motion."
}
