{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-flow",
  "title": "Number Flow",
  "description": "An animated, digit-by-digit number counter with a smooth rolling odometer effect, powered by Motion.",
  "dependencies": ["motion"],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/primitives/texts/number-flow/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  type Transition,\n  type UseInViewOptions,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type CSSProperties,\n  type HTMLAttributes,\n  type Ref,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n// --- formatting -------------------------------------------------------------\n\ntype NumberPartType =\n  | Exclude<Intl.NumberFormatPartTypes, \"minusSign\" | \"plusSign\">\n  | \"sign\"\n  | \"prefix\"\n  | \"suffix\";\n\ninterface KeyedDigitPart {\n  key: string;\n  /** Digit place: 0 = ones, 1 = tens, ... for integer; -1, -2, ... for fraction. */\n  pos: number;\n  type: \"integer\" | \"fraction\";\n  value: number;\n}\n\ninterface KeyedSymbolPart {\n  key: string;\n  type: Exclude<NumberPartType, \"integer\" | \"fraction\">;\n  value: string;\n}\n\ntype KeyedPart = KeyedDigitPart | KeyedSymbolPart;\n\ninterface FormattedNumber {\n  parts: KeyedPart[];\n  value: number;\n  valueAsString: string;\n}\n\nfunction generateKeyFactory() {\n  const counts: Partial<Record<NumberPartType, number>> = {};\n  return (type: NumberPartType) => {\n    const next = (counts[type] ?? -1) + 1;\n    counts[type] = next;\n    return { index: next, key: `${type}:${next}` };\n  };\n}\n\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: part-classification switch\nfunction formatToParts(\n  value: number,\n  formatter: Intl.NumberFormat,\n  prefix?: string,\n  suffix?: string\n): FormattedNumber {\n  const rawParts: Array<{\n    type: Intl.NumberFormatPartTypes | \"prefix\" | \"suffix\";\n    value: string;\n  }> = formatter.formatToParts(value);\n  if (prefix) {\n    rawParts.unshift({ type: \"prefix\", value: prefix });\n  }\n  if (suffix) {\n    rawParts.push({ type: \"suffix\", value: suffix });\n  }\n\n  const pre: KeyedPart[] = [];\n  const integerDigits: Array<\n    { type: \"integer\"; value: number } | { type: \"group\"; value: string }\n  > = [];\n  const fraction: KeyedPart[] = [];\n  const post: KeyedPart[] = [];\n\n  const generateKey = generateKeyFactory();\n\n  let valueAsString = \"\";\n  let seenInteger = false;\n  let seenDecimal = false;\n\n  for (const part of rawParts) {\n    valueAsString += part.value;\n\n    const type: NumberPartType =\n      part.type === \"minusSign\" || part.type === \"plusSign\"\n        ? \"sign\"\n        : part.type;\n\n    if (type === \"integer\") {\n      seenInteger = true;\n      for (const digit of part.value) {\n        integerDigits.push({ type: \"integer\", value: Number(digit) });\n      }\n    } else if (type === \"group\") {\n      integerDigits.push({ type: \"group\", value: part.value });\n    } else if (type === \"decimal\") {\n      seenDecimal = true;\n      fraction.push({\n        type: \"decimal\",\n        value: part.value,\n        key: generateKey(type).key,\n      });\n    } else if (type === \"fraction\") {\n      for (const digit of part.value) {\n        // Fraction digits are keyed left-to-right, so pos counts down from -1.\n        const { index, key } = generateKey(type);\n        fraction.push({\n          type: \"fraction\",\n          value: Number(digit),\n          key,\n          pos: -1 - index,\n        });\n      }\n    } else {\n      (seenInteger || seenDecimal ? post : pre).push({\n        type,\n        value: part.value,\n        key: generateKey(type).key,\n      });\n    }\n  }\n\n  // Key the integer parts right-to-left, so adding/removing a leading digit\n  // (e.g. 99 -> 100) doesn't reshuffle the keys of the digits that didn't change.\n  // The right-to-left index also becomes each digit's place (`pos`): ones = 0,\n  // tens = 1, etc.\n  const integer: KeyedPart[] = [...integerDigits]\n    .reverse()\n    .map((part) => {\n      const { index, key } = generateKey(part.type);\n      return part.type === \"integer\"\n        ? { ...part, key, pos: index }\n        : { ...part, key };\n    })\n    .reverse();\n\n  return {\n    parts: [...pre, ...integer, ...fraction, ...post],\n    valueAsString,\n    value,\n  };\n}\n\nfunction isDigitPart(part: KeyedPart): part is KeyedDigitPart {\n  return part.type === \"integer\" || part.type === \"fraction\";\n}\n\n// --- trend / digit delta -----------------------------------------------------\n\nexport type NumberFlowTrend =\n  | number\n  | ((prevValue: number, value: number) => number);\n\nfunction resolveTrend(\n  trend: NumberFlowTrend,\n  prevValue: number,\n  value: number\n) {\n  return typeof trend === \"function\" ? trend(prevValue, value) : trend;\n}\n\nconst DIGIT_LENGTH = 10;\n\nfunction getDigitDelta(prevValue: number, value: number, trend: number) {\n  const diff = value - prevValue;\n  const t = trend || Math.sign(diff);\n  if (t < 0 && value > prevValue) {\n    return value - DIGIT_LENGTH - prevValue;\n  }\n  if (t > 0 && value < prevValue) {\n    return DIGIT_LENGTH - prevValue + value;\n  }\n  return diff;\n}\n\nfunction mod(a: number, m: number) {\n  return ((a % m) + m) % m;\n}\n\n/**\n * Ported from the `continuous` plugin (packages/number-flow/src/plugins/continuous.ts).\n * Finds the least-significant digit place where the previous and next number\n * actually differ, so that digits at-or-before that place which happen to\n * hold the same face still spin through a full loop — making the number\n * appear to pass continuously through the values in between, instead of just\n * the changed digits jumping in place.\n */\nfunction computeStartingPos(\n  prevParts: KeyedDigitPart[],\n  parts: KeyedDigitPart[],\n  trend: number\n): number | undefined {\n  if (!trend) {\n    return;\n  }\n  const firstChangedPrev = prevParts.find(\n    (pp) => !parts.some((p) => p.pos === pp.pos && p.value === pp.value)\n  );\n  const firstChanged = parts.find(\n    (p) => !prevParts.some((pp) => p.pos === pp.pos && p.value === pp.value)\n  );\n  const positions = [firstChangedPrev?.pos, firstChanged?.pos].filter(\n    (p): p is number => p != null\n  );\n  return positions.length ? Math.max(...positions) : undefined;\n}\n\n/** Fraction in [-1, 1] of one row that face `n` sits from center. */\nfunction digitFaceOffset(n: number, c: number) {\n  const offsetRaw = mod(DIGIT_LENGTH + n - mod(c, DIGIT_LENGTH), DIGIT_LENGTH);\n  const offset =\n    offsetRaw - DIGIT_LENGTH * Math.floor(offsetRaw / (DIGIT_LENGTH / 2));\n  return Math.max(-1, Math.min(1, offset));\n}\n\n// --- edge mask (vignette) -------------------------------------------------\n\n/**\n * Ported from the mask recipe in packages/number-flow/src/styles.ts\n * (technique: https://expensive.toys/blog/blur-vignette). Fades the digit\n * row through its top/bottom/corner edges instead of hard-clipping spinning\n * faces, so a digit rolling in/out doesn't look like it's cut off by a box.\n */\nconst MASK_HEIGHT = \"0.25em\";\nconst MASK_WIDTH = \"0.5em\";\nconst MASK_CORNER = \"#000 0, transparent 71%\";\n\nconst numberMaskStyle: CSSProperties = {\n  WebkitMaskImage: [\n    `linear-gradient(to right, transparent 0, #000 ${MASK_WIDTH}, #000 calc(100% - ${MASK_WIDTH}), transparent)`,\n    `linear-gradient(to bottom, transparent 0, #000 ${MASK_HEIGHT}, #000 calc(100% - ${MASK_HEIGHT}), transparent 100%)`,\n    `radial-gradient(at bottom right, ${MASK_CORNER})`,\n    `radial-gradient(at bottom left, ${MASK_CORNER})`,\n    `radial-gradient(at top left, ${MASK_CORNER})`,\n    `radial-gradient(at top right, ${MASK_CORNER})`,\n  ].join(\", \"),\n  WebkitMaskPosition:\n    \"center, center, top left, top right, bottom right, bottom left\",\n  WebkitMaskRepeat: \"no-repeat\",\n  WebkitMaskSize: [\n    `100% calc(100% - ${MASK_HEIGHT} * 2)`,\n    `calc(100% - ${MASK_WIDTH} * 2) 100%`,\n    `${MASK_WIDTH} ${MASK_HEIGHT}`,\n    `${MASK_WIDTH} ${MASK_HEIGHT}`,\n    `${MASK_WIDTH} ${MASK_HEIGHT}`,\n    `${MASK_WIDTH} ${MASK_HEIGHT}`,\n  ].join(\", \"),\n  marginInline: `calc(-1 * ${MASK_WIDTH})`,\n};\n\n// Compensates the outer element's negative margin, reserving blank space for\n// the mask's fade zone so it doesn't cut into actual digit ink.\nconst numberMaskInnerStyle: CSSProperties = {\n  padding: `calc(${MASK_HEIGHT} / 2) ${MASK_WIDTH}`,\n};\n\nconst DIGIT_FACES = Array.from({ length: DIGIT_LENGTH }, (_, i) => i);\n\n// --- Digit --------------------------------------------------------------\n\ninterface DigitFaceProps {\n  mv: MotionValue<number>;\n  n: number;\n  willChange: boolean;\n}\n\nfunction DigitFace({ n, mv, willChange }: DigitFaceProps) {\n  const y = useTransform(mv, (c) => `${digitFaceOffset(n, c) * 100}%`);\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center\",\n        willChange && \"will-change-transform\"\n      )}\n      style={{ y }}\n    >\n      {n}\n    </motion.span>\n  );\n}\n\ninterface DigitProps {\n  animateIn: boolean;\n  layoutTransition: Transition;\n  /** Called right before this digit starts a spin animation. */\n  onAnimationEnd: () => void;\n  /** Called once this digit's spin animation settles (naturally or interrupted). */\n  onAnimationStart: () => void;\n  /** Digit place (see `KeyedDigitPart.pos`); used for the continuous-loop effect. */\n  pos: number;\n  reduced: boolean;\n  ref?: Ref<HTMLSpanElement>;\n  /** Least-significant place at/before which unchanged digits still loop once. */\n  startingPos: number | undefined;\n  transition: Transition;\n  trend: number;\n  value: number;\n  willChange: boolean;\n}\n\n// `mode=\"popLayout\"` needs to grab this component's underlying DOM node to pop\n// it out of flow while it exits (see AnimatePresence usage below) — without\n// forwarding `ref`, the exiting digit stays in flow for its whole fade, and\n// siblings only slide into the gap it leaves behind once it finally unmounts.\nfunction Digit({\n  value,\n  trend,\n  transition,\n  layoutTransition,\n  animateIn,\n  reduced,\n  pos,\n  startingPos,\n  willChange,\n  onAnimationStart,\n  onAnimationEnd,\n  ref,\n}: DigitProps) {\n  const mv = useMotionValue(reduced || !animateIn ? value : 0);\n  const isMountRef = useRef(true);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: startingPos must also retrigger the roll, for digits whose own value didn't change (continuous-loop effect)\n  useEffect(() => {\n    // Runs a spin and reports it to the parent's onAnimationsStart/Finish\n    // aggregate — settled fires exactly once whether the spin completes\n    // naturally or gets interrupted (stop()'d) by the next update/unmount.\n    const spin = (delta: number) => {\n      onAnimationStart();\n      let settled = false;\n      const settle = () => {\n        if (settled) {\n          return;\n        }\n        settled = true;\n        onAnimationEnd();\n      };\n      const controls = animate(mv, delta, transition);\n      controls.then(settle, settle);\n      return () => {\n        controls.stop();\n        settle();\n      };\n    };\n\n    if (isMountRef.current) {\n      isMountRef.current = false;\n      if (!reduced && animateIn) {\n        return spin(getDigitDelta(0, value, trend));\n      }\n      return;\n    }\n\n    if (reduced) {\n      mv.set(value);\n      return;\n    }\n\n    // Rebase off the motion value's current position (possibly mid-flight) so\n    // rapid updates still converge to the correct face.\n    const current = mv.get();\n    let delta = getDigitDelta(mod(current, DIGIT_LENGTH), value, trend);\n    if (delta === 0) {\n      // This digit's own face didn't change, but a less-significant digit did\n      // — loop it through a full rotation so the whole number reads as\n      // passing continuously through the intermediate values.\n      if (trend && startingPos != null && startingPos >= pos) {\n        delta = DIGIT_LENGTH * trend;\n      } else {\n        return;\n      }\n    }\n    return spin(current + delta);\n  }, [value, startingPos]);\n\n  return (\n    <motion.span\n      animate={{ opacity: 1 }}\n      // No `overflow-hidden` here on purpose: the row-level mask (see\n      // numberMaskStyle) is what hides/fades the sliding faces. A hard clip\n      // here would cut them off before the mask ever gets to soften them, and\n      // (per the original) `overflow:clip` also breaks baseline alignment in\n      // Safari.\n      className={cn(\n        \"relative inline-block align-bottom tabular-nums\",\n        willChange && \"will-change-transform\"\n      )}\n      exit={{ opacity: 0 }}\n      initial={{ opacity: 0 }}\n      layout\n      ref={ref}\n      transition={layoutTransition}\n    >\n      <span aria-hidden=\"true\" className=\"invisible\">\n        0\n      </span>\n      {DIGIT_FACES.map((n) => (\n        <DigitFace key={n} mv={mv} n={n} willChange={willChange} />\n      ))}\n    </motion.span>\n  );\n}\n\n// --- NumberFlow -----------------------------------------------------------\n\nconst DEFAULT_TRANSITION: Transition = {\n  duration: 0.9,\n  ease: [0.16, 1, 0.3, 1],\n};\n\nconst DEFAULT_OPACITY_TRANSITION: Transition = {\n  duration: 0.45,\n  ease: \"easeOut\",\n};\n\nconst REDUCED_TRANSITION: Transition = { duration: 0 };\n\nconst DEFAULT_VIEWPORT_MARGIN = \"0px 0px -10% 0px\";\n\nexport interface NumberFlowProps\n  extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** Passed to `Intl.NumberFormat`. */\n  format?: Intl.NumberFormatOptions;\n  /** Passed to `Intl.NumberFormat`. */\n  locales?: Intl.LocalesArgument;\n  /** Called once every digit spun by the current value update has settled. */\n  onAnimationsFinish?: () => void;\n  /**\n   * Called once when a value update starts spinning any digit, after any\n   * previous batch has fully settled.\n   */\n  onAnimationsStart?: () => void;\n  /**\n   * When `scrollTrigger` is enabled, fire the count-up only once.\n   * @default true\n   */\n  once?: boolean;\n  /** Rendered before the formatted value, e.g. a currency symbol override. */\n  prefix?: string;\n  /** Skip animation when the user has requested reduced motion (default true). */\n  respectMotionPreference?: boolean;\n  /**\n   * Count up from 0 the first (or, with `once={false}`, every) time this\n   * element enters the viewport, instead of showing the final value\n   * immediately. Ignored when reduced motion is active.\n   * @default false\n   */\n  scrollTrigger?: boolean;\n  /** Overrides `transition` for the digit roll specifically. */\n  spinTransition?: Transition;\n  /** Rendered after the formatted value, e.g. a unit. */\n  suffix?: string;\n  /** Transition for the digit roll and layout reflow. */\n  transition?: Transition;\n  /**\n   * Controls which direction digits spin. A number's sign is used directly;\n   * a function receives `(prevValue, value)` and should return one. Defaults\n   * to `Math.sign(value - prevValue)`.\n   */\n  trend?: NumberFlowTrend;\n  /** The number to display. Changing this triggers the roll animation. */\n  value: number;\n  /**\n   * Intersection margin used when `scrollTrigger` is enabled.\n   * @default \"0px 0px -10% 0px\"\n   */\n  viewportMargin?: UseInViewOptions[\"margin\"];\n  /**\n   * Hints the browser to promote digits/symbols onto their own composite\n   * layer ahead of time. Costs GPU memory, so only turn it on for instances\n   * that animate often (e.g. a live-updating counter), not static ones.\n   * @default false\n   */\n  willChange?: boolean;\n}\n\nfunction usePrevious<T>(value: T) {\n  const ref = useRef(value);\n  const prev = ref.current;\n  ref.current = value;\n  return prev;\n}\n\nfunction useIsFirstRender() {\n  const ref = useRef(true);\n  const isFirst = ref.current;\n  ref.current = false;\n  return isFirst;\n}\n\n/** Skip animating when detached, hidden, or the document tab is backgrounded. */\nfunction canAnimateElement(el: HTMLElement | null) {\n  if (\n    typeof document !== \"undefined\" &&\n    document.visibilityState !== \"visible\"\n  ) {\n    return false;\n  }\n  return Boolean(el && el.offsetWidth > 0 && el.offsetHeight > 0);\n}\n\nexport function NumberFlow({\n  value,\n  locales,\n  format,\n  prefix,\n  suffix,\n  trend = (prevValue, next) => Math.sign(next - prevValue),\n  transition = DEFAULT_TRANSITION,\n  spinTransition,\n  respectMotionPreference = true,\n  willChange = false,\n  scrollTrigger = false,\n  once = true,\n  viewportMargin = DEFAULT_VIEWPORT_MARGIN,\n  onAnimationsStart,\n  onAnimationsFinish,\n  className,\n  style,\n  ...props\n}: NumberFlowProps) {\n  const prefersReducedMotion = useReducedMotion();\n  const isFirstRender = useIsFirstRender();\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const isInView = useInView(rootRef, { margin: viewportMargin, once });\n  // Aggregates every digit's spin so onAnimationsStart/Finish (and the\n  // will-change toggle below) fire once per batch (0 -> >0, then back to 0),\n  // not once per digit.\n  const activeSpins = useRef(0);\n  // `will-change` should only cost a GPU layer while digits are actually\n  // spinning — promoting them for the component's entire lifetime would leak\n  // memory for instances that update rarely. Gated by `willChange` so opting\n  // out skips the extra state/re-renders entirely.\n  const [isAnimating, setIsAnimating] = useState(false);\n  const handleAnimationStart = () => {\n    activeSpins.current += 1;\n    if (activeSpins.current === 1) {\n      onAnimationsStart?.();\n      if (willChange) {\n        setIsAnimating(true);\n      }\n    }\n  };\n  const handleAnimationEnd = () => {\n    activeSpins.current -= 1;\n    if (activeSpins.current === 0) {\n      onAnimationsFinish?.();\n      if (willChange) {\n        setIsAnimating(false);\n      }\n    }\n  };\n  const activeWillChange = willChange && isAnimating;\n  // Re-checked every render so off-screen or backgrounded instances skip\n  // animation on the next value update.\n  const reduced =\n    Boolean(respectMotionPreference && prefersReducedMotion) ||\n    !(isFirstRender || canAnimateElement(rootRef.current));\n\n  // Holds at 0 until the element scrolls into view, then flips to the real\n  // value — which flows through the normal update path below and counts up\n  // exactly like any other value change. Ignored under reduced motion so\n  // motion-sensitive users see the final value immediately instead of\n  // waiting on scroll position for content that won't animate anyway.\n  const effectiveValue = scrollTrigger && !reduced && !isInView ? 0 : value;\n\n  const localesKey = JSON.stringify(locales ?? null);\n  const formatKey = JSON.stringify(format ?? null);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed by the serialized locales/format, not their (possibly-new-every-render) identity\n  const formatter = useMemo(\n    () => new Intl.NumberFormat(locales, format),\n    [localesKey, formatKey]\n  );\n\n  const data = useMemo(\n    () => formatToParts(effectiveValue, formatter, prefix, suffix),\n    [effectiveValue, formatter, prefix, suffix]\n  );\n\n  const prevData = usePrevious(data);\n  const computedTrend = resolveTrend(trend, prevData.value, data.value);\n\n  const startingPos = useMemo(\n    () =>\n      computeStartingPos(\n        prevData.parts.filter(isDigitPart),\n        data.parts.filter(isDigitPart),\n        computedTrend\n      ),\n    [prevData, data, computedTrend]\n  );\n\n  const spinT = reduced ? REDUCED_TRANSITION : (spinTransition ?? transition);\n  const layoutT = reduced ? REDUCED_TRANSITION : transition;\n  const opacityT = reduced ? REDUCED_TRANSITION : DEFAULT_OPACITY_TRANSITION;\n\n  return (\n    <span\n      aria-label={data.valueAsString}\n      className={cn(\"inline-block tabular-nums\", className)}\n      dir=\"ltr\"\n      ref={rootRef}\n      role=\"img\"\n      style={{ ...numberMaskStyle, ...style }}\n      {...props}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"isolate inline-flex\",\n          activeWillChange && \"will-change-transform\"\n        )}\n        style={numberMaskInnerStyle}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {data.parts.map((part) =>\n            isDigitPart(part) ? (\n              <Digit\n                animateIn={!isFirstRender}\n                key={part.key}\n                layoutTransition={layoutT}\n                onAnimationEnd={handleAnimationEnd}\n                onAnimationStart={handleAnimationStart}\n                pos={part.pos}\n                reduced={reduced}\n                startingPos={startingPos}\n                transition={spinT}\n                trend={computedTrend}\n                value={part.value}\n                willChange={activeWillChange}\n              />\n            ) : (\n              <motion.span\n                animate={{ opacity: 1 }}\n                // plus-lighter (not source-over) so an exiting and entering\n                // symbol at the same spot add together instead of\n                // double-darkening while both are partially opaque mid-fade\n                // (e.g. a sign flipping from \"+\" to \"-\").\n                className={cn(\n                  \"inline-block mix-blend-plus-lighter\",\n                  activeWillChange && \"will-change-transform\"\n                )}\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                key={part.key}\n                layout\n                transition={opacityT}\n              >\n                {part.value}\n              </motion.span>\n            )\n          )}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/texts/number-flow.tsx"
    }
  ],
  "meta": {
    "inspiration": {
      "type": "reimplemented",
      "label": "Number Flow",
      "url": "https://number-flow.barvian.me/",
      "stack": "Motion and React"
    }
  },
  "type": "registry:ui"
}
