{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-flow",
  "title": "Number flow",
  "description": "Animated numbers where only the changed digits spin, with any Intl format and locale.",
  "dependencies": [
    "cn"
  ],
  "registryDependencies": [
    "https://hextaui.com/r/theme.json",
    "https://hextaui.com/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/number-flow.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport { easeSpring } from \"@/lib/motion\"\n\ntype NumberFlowTrend = \"auto\" | \"up\" | \"down\" | \"shortest\"\n\ntype NumberFlowChar = {\n  key: string\n  section: number\n  order: number\n  value: string\n  digit: number | null\n}\n\ntype NumberFlowItem = NumberFlowChar & {\n  exiting: boolean\n  exitId: number\n}\n\ntype NumberFlowProps = Omit<\n  React.ComponentProps<\"span\">,\n  \"children\" | \"prefix\"\n> & {\n  value: number\n  locales?: Intl.LocalesArgument\n  format?: Intl.NumberFormatOptions\n  prefix?: string\n  suffix?: string\n  trend?: NumberFlowTrend\n  duration?: number\n  easing?: string\n  animated?: boolean\n  onAnimationsStart?: () => void\n  onAnimationsFinish?: () => void\n}\n\nconst rows = 30\n\nconst restingClasses =\n  \"data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]\"\n\nfunction getSpinDelta(\n  from: number,\n  to: number,\n  trend: Exclude<NumberFlowTrend, \"auto\">\n) {\n  let delta = to - from\n\n  if (trend === \"up\") {\n    if (delta < 0) {\n      delta += 10\n    }\n  } else if (trend === \"down\") {\n    if (delta > 0) {\n      delta -= 10\n    }\n  } else if (delta > 5) {\n    delta -= 10\n  } else if (delta < -4) {\n    delta += 10\n  }\n\n  return delta\n}\n\nfunction digitGlyphs(\n  formatter: Intl.NumberFormat,\n  locales: Intl.LocalesArgument\n) {\n  const { numberingSystem } = formatter.resolvedOptions()\n  const plain = new Intl.NumberFormat(locales, {\n    numberingSystem,\n    useGrouping: false,\n  })\n\n  return Array.from({ length: 10 }, (_, digit) => plain.format(digit))\n}\n\nfunction formatChars(\n  value: number,\n  locales: Intl.LocalesArgument,\n  format: Intl.NumberFormatOptions | undefined,\n  prefix: string | undefined,\n  suffix: string | undefined\n) {\n  const formatter = new Intl.NumberFormat(locales, format)\n  const glyphs = digitGlyphs(formatter, locales)\n  const parts = formatter.formatToParts(value)\n  const integerCount = parts\n    .filter((part) => part.type === \"integer\")\n    .reduce((count, part) => count + Array.from(part.value).length, 0)\n  const chars: NumberFlowChar[] = []\n  const occurrences = new Map<string, number>()\n  let integerIndex = 0\n  let fractionIndex = 0\n  let seenNumber = false\n\n  function symbol(section: number, type: string, text: string) {\n    const name = `${section}-${type}`\n    const occurrence = occurrences.get(name) ?? 0\n    occurrences.set(name, occurrence + 1)\n    chars.push({\n      key: `s${name}-${occurrence}`,\n      section,\n      order: occurrence,\n      value: text,\n      digit: null,\n    })\n  }\n\n  if (prefix) {\n    chars.push({\n      key: \"prefix\",\n      section: 0,\n      order: 0,\n      value: prefix,\n      digit: null,\n    })\n  }\n\n  for (const part of parts) {\n    if (part.type === \"integer\") {\n      seenNumber = true\n      for (const glyph of Array.from(part.value)) {\n        const place = integerCount - 1 - integerIndex\n        const digit = glyphs.indexOf(glyph)\n        chars.push({\n          key: `i${place}`,\n          section: 2,\n          order: -place,\n          value: glyph,\n          digit: digit === -1 ? null : digit,\n        })\n        integerIndex += 1\n      }\n    } else if (part.type === \"group\") {\n      const place = integerCount - 1 - integerIndex\n      chars.push({\n        key: `g${place}`,\n        section: 2,\n        order: -place - 0.5,\n        value: part.value,\n        digit: null,\n      })\n    } else if (part.type === \"decimal\") {\n      seenNumber = true\n      chars.push({\n        key: \"decimal\",\n        section: 3,\n        order: 0,\n        value: part.value,\n        digit: null,\n      })\n    } else if (part.type === \"fraction\") {\n      for (const glyph of Array.from(part.value)) {\n        const digit = glyphs.indexOf(glyph)\n        chars.push({\n          key: `f${fractionIndex}`,\n          section: 4,\n          order: fractionIndex,\n          value: glyph,\n          digit: digit === -1 ? null : digit,\n        })\n        fractionIndex += 1\n      }\n    } else {\n      symbol(seenNumber ? 5 : 1, part.type, part.value)\n    }\n  }\n\n  if (suffix) {\n    chars.push({\n      key: \"suffix\",\n      section: 6,\n      order: 0,\n      value: suffix,\n      digit: null,\n    })\n  }\n\n  return chars\n}\n\nfunction signature(chars: NumberFlowChar[]) {\n  return chars.map((char) => `${char.key}:${char.value}`).join(\"|\")\n}\n\nfunction mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) {\n  const keys = new Set(next.map((char) => char.key))\n  const kept = previous\n    .filter((item) => !keys.has(item.key))\n    .map((item) =>\n      item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 }\n    )\n  const fresh = next.map((char) => {\n    const before = previous.find((item) => item.key === char.key)\n    return { ...char, exiting: false, exitId: before?.exitId ?? 0 }\n  })\n\n  return [...kept, ...fresh].sort(\n    (a, b) => a.section - b.section || a.order - b.order\n  )\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  if (typeof window.matchMedia !== \"function\") {\n    return () => {}\n  }\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  query.addEventListener(\"change\", callback)\n  return () => query.removeEventListener(\"change\", callback)\n}\n\nfunction getReducedMotion() {\n  return (\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  )\n}\n\nfunction canAnimate() {\n  return (\n    typeof Element !== \"undefined\" &&\n    typeof Element.prototype.animate === \"function\"\n  )\n}\n\nfunction liveIndex(column: HTMLElement) {\n  const height = column.offsetHeight\n\n  if (!height) {\n    return null\n  }\n\n  const translate = getComputedStyle(column).translate\n  const y = translate === \"none\" ? \"0\" : (translate.split(\" \")[1] ?? \"0\")\n  const amount = parseFloat(y) || 0\n\n  return y.endsWith(\"%\") ? (-amount / 100) * rows : (-amount / height) * rows\n}\n\nfunction NumberFlow({\n  value,\n  locales = \"en-US\",\n  format,\n  prefix,\n  suffix,\n  trend = \"auto\",\n  duration = 600,\n  easing = easeSpring,\n  animated = true,\n  onAnimationsStart,\n  onAnimationsFinish,\n  className,\n  ...props\n}: NumberFlowProps) {\n  const chars = formatChars(value, locales, format, prefix, suffix)\n  const key = signature(chars)\n  const reducedMotion = React.useSyncExternalStore(\n    subscribeReducedMotion,\n    getReducedMotion,\n    () => false\n  )\n  const animate = animated && !reducedMotion && canAnimate()\n  const [state, setState] = React.useState(() => ({\n    key,\n    items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),\n  }))\n\n  if (state.key !== key) {\n    setState({\n      key,\n      items: animate\n        ? mergeItems(state.items, chars)\n        : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),\n    })\n  }\n\n  const rootRef = React.useRef<HTMLSpanElement>(null)\n  const previousRef = React.useRef<Map<\n    string,\n    { digit: number | null; exiting: boolean; exitId: number }\n  > | null>(null)\n  const valueRef = React.useRef(value)\n  const batchRef = React.useRef(0)\n  const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish })\n  const finishExitRef = React.useRef((itemKey: string, exitId: number) => {\n    setState((current) => ({\n      ...current,\n      items: current.items.filter(\n        (item) =>\n          !(item.key === itemKey && item.exiting && item.exitId === exitId)\n      ),\n    }))\n  })\n\n  React.useLayoutEffect(() => {\n    callbacksRef.current = { onAnimationsStart, onAnimationsFinish }\n  })\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current\n    const previous = previousRef.current\n    const current = new Map(\n      state.items.map((item) => [\n        item.key,\n        { digit: item.digit, exiting: item.exiting, exitId: item.exitId },\n      ])\n    )\n    const previousValue = valueRef.current\n\n    previousRef.current = current\n    valueRef.current = value\n\n    if (!root || !previous || !animate) {\n      return\n    }\n\n    const direction =\n      trend !== \"auto\"\n        ? trend\n        : value > previousValue\n          ? \"up\"\n          : value < previousValue\n            ? \"down\"\n            : \"shortest\"\n    const timing = { duration, easing }\n    const started: Animation[] = []\n    const cells = root.querySelectorAll<HTMLElement>(\n      \":scope > [data-number-flow-key]\"\n    )\n\n    for (const cell of cells) {\n      const itemKey = cell.dataset.numberFlowKey!\n      const item = current.get(itemKey)\n      const before = previous.get(itemKey)\n\n      if (!item || typeof cell.animate !== \"function\") {\n        continue\n      }\n\n      const column = cell.querySelector<HTMLElement>(\n        \"[data-slot=number-flow-column]\"\n      )\n      const entering = !item.exiting && (!before || before.exiting)\n      const leaving = item.exiting && (!before || !before.exiting)\n\n      if (entering || leaving) {\n        const running = cell.getAnimations()\n        const fromWidth = running.length\n          ? cell.getBoundingClientRect().width\n          : entering\n            ? 0\n            : cell.getBoundingClientRect().width\n        const fromOpacity = running.length\n          ? Number(getComputedStyle(cell).opacity)\n          : entering\n            ? 0\n            : 1\n        running.forEach((animation) => animation.cancel())\n        const toWidth = entering ? cell.getBoundingClientRect().width : 0\n        const animation = cell.animate(\n          [\n            { width: `${fromWidth}px`, opacity: fromOpacity },\n            { width: `${toWidth}px`, opacity: entering ? 1 : 0 },\n          ],\n          { ...timing, fill: leaving ? \"forwards\" : \"none\" }\n        )\n        started.push(animation)\n        if (leaving) {\n          const exitId = item.exitId\n          animation.finished.then(\n            () => finishExitRef.current(itemKey, exitId),\n            () => {}\n          )\n        }\n      }\n\n      if (!column || item.digit === null || item.exiting) {\n        continue\n      }\n\n      const target = item.digit\n      const running = column.getAnimations()\n      const live = running.length ? liveIndex(column) : null\n      running.forEach((animation) => animation.cancel())\n      const from =\n        live !== null\n          ? live - 10\n          : entering\n            ? 0\n            : before && before.digit !== null\n              ? before.digit\n              : target\n\n      if (live === null && from === target && !entering) {\n        continue\n      }\n\n      const delta = getSpinDelta(\n        from,\n        target,\n        entering && live === null ? \"up\" : direction\n      )\n\n      if (delta === 0) {\n        continue\n      }\n\n      started.push(\n        column.animate(\n          [\n            { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` },\n            { translate: `0 ${(-(target + 10) / rows) * 100}%` },\n          ],\n          timing\n        )\n      )\n    }\n\n    if (!started.length) {\n      return\n    }\n\n    batchRef.current += 1\n    const batch = batchRef.current\n    callbacksRef.current.onAnimationsStart?.()\n    Promise.all(started.map((animation) => animation.finished)).then(\n      () => {\n        if (batch === batchRef.current) {\n          callbacksRef.current.onAnimationsFinish?.()\n        }\n      },\n      () => {}\n    )\n  }, [state.items, animate, duration, easing, trend, value])\n\n  return (\n    <span\n      ref={rootRef}\n      dir=\"ltr\"\n      className={cn(\"inline-block max-w-full tabular-nums\", className)}\n      {...props}\n      data-slot=\"number-flow\"\n    >\n      <span data-slot=\"number-flow-text\" className=\"sr-only\">\n        {state.items\n          .filter((item) => !item.exiting)\n          .map((item) => item.value)\n          .join(\"\")}\n      </span>\n      {state.items.map((item) =>\n        item.digit === null ? (\n          <span\n            key={item.key}\n            data-number-flow-key={item.key}\n            data-slot=\"number-flow-symbol\"\n            aria-hidden\n            className=\"inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap\"\n          >\n            {item.value}\n          </span>\n        ) : (\n          <span\n            key={item.key}\n            data-number-flow-key={item.key}\n            data-slot=\"number-flow-digit\"\n            aria-hidden\n            className=\"relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]\"\n          >\n            <span className=\"opacity-0\">{item.value}</span>\n            <span\n              aria-hidden\n              data-slot=\"number-flow-column\"\n              data-digit={item.digit}\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none\",\n                restingClasses\n              )}\n            >\n              {Array.from({ length: rows }, (_, row) => (\n                <span key={row} className=\"block py-[0.2em]\">\n                  {glyphFor(item, row)}\n                </span>\n              ))}\n            </span>\n          </span>\n        )\n      )}\n    </span>\n  )\n}\n\nfunction glyphFor(item: NumberFlowItem, row: number) {\n  return String.fromCodePoint(\n    item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10)\n  )\n}\n\nexport {\n  NumberFlow,\n  getSpinDelta,\n  formatChars,\n  type NumberFlowProps,\n  type NumberFlowTrend,\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}