{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-ticker",
  "type": "registry:ui",
  "title": "Number Ticker",
  "description": "Rolls each digit toward its new value in the direction it moved, tinting briefly.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/number-ticker.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nconst noop = () => () => {};\n\n/** The reader's locale on the client, a fixed one on the server, so hydration always matches. */\nexport function useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.NumberFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\nexport type NumberFormatShortcuts = {\n  /** ISO 4217 code. Formats as money with the narrow symbol: \"$\", \"€\", \"¥\". */\n  currency?: string;\n  /** Short scale: 1.2K, 48.2M. One decimal at most. */\n  compact?: boolean;\n  /** Fixed fraction digits, so a value never jumps between \"4.1\" and \"4.10\". */\n  decimals?: number;\n  /** Any Intl.NumberFormat option, applied last. Scientific notation isn't supported. */\n  format?: Format;\n};\n\n/** Turns the shortcuts into one Intl options object. Shared by every number in the data category. */\nexport function numberFormatOptions({ currency, compact, decimals, format }: NumberFormatShortcuts): Format {\n  return {\n    ...(currency ? { style: \"currency\", currency, currencyDisplay: \"narrowSymbol\" } : null),\n    ...(compact ? { notation: \"compact\", maximumFractionDigits: 1 } : null),\n    ...(decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : null),\n    ...format,\n  };\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\n// Everyday changes settle in 600ms; the first count-up from `from` is given longer to travel.\nconst timing = (ms: number) => ({\n  transformTiming: { duration: ms, easing: expo },\n  spinTiming: { duration: ms, easing: expo },\n  opacityTiming: { duration: 240, easing: \"ease-out\" },\n});\n\nexport type NumberTickerProps = Omit<React.ComponentProps<\"span\">, \"children\" | \"prefix\"> &\n  NumberFormatShortcuts & {\n    value: number;\n    /** BCP 47 tag. Defaults to the reader's locale. */\n    locale?: string;\n    /** Text that sits inside the number and moves with it: \"~\", \"+\". */\n    prefix?: string;\n    suffix?: string;\n    /** Which way digits roll. \"auto\" rolls up when the value rises and down when it falls. */\n    direction?: \"auto\" | \"up\" | \"down\";\n    /** Tint the digits success or danger for a moment after each change. */\n    flash?: boolean;\n    /** For values where down is good (latency, churn, cost): falling flashes success. */\n    inverse?: boolean;\n    /** Shown until the number first scrolls into view, then it counts to `value`, once. */\n    from?: number;\n    /** Set false to swap digits without rolling. Reduced motion does this on its own. */\n    animated?: boolean;\n  };\n\nexport function NumberTicker({\n  value,\n  locale: localeProp,\n  currency,\n  compact,\n  decimals,\n  format,\n  prefix,\n  suffix,\n  direction = \"auto\",\n  flash = false,\n  inverse = false,\n  from,\n  animated = true,\n  className,\n  ref,\n  ...rest\n}: NumberTickerProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const [el, setEl] = useState<HTMLSpanElement | null>(null);\n  const [seen, setSeen] = useState(from == null);\n  const [finished, setFinished] = useState(false);\n  const prev = useRef<number | null>(null);\n  const node = useRef<HTMLSpanElement | null>(null);\n\n  const setRefs = useCallback(\n    (n: HTMLSpanElement | null) => {\n      node.current = n;\n      setEl(n);\n      if (typeof ref === \"function\") ref(n);\n      else if (ref) ref.current = n;\n    },\n    [ref],\n  );\n\n  // Count up once, the first time at least half of it is on screen.\n  useEffect(() => {\n    if (seen || !el) return;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (!e.isIntersecting) return;\n        setSeen(true);\n        io.disconnect();\n      },\n      { threshold: 0.5 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [el, seen]);\n\n  const shown = seen ? value : (from ?? value);\n  const rolls = animated && !reduce;\n  // The count-up is over once it has landed, or at once when there's nothing to roll.\n  const counted = from == null || (seen && (finished || !rolls || from === value));\n\n  // The tint rides on the Web Animations API so a new change restarts it from\n  // where it is, and nothing re-renders to fade it out.\n  useEffect(() => {\n    const last = prev.current;\n    prev.current = shown;\n    const target = node.current;\n    if (last == null || last === shown || !target) return;\n    const up = shown > last;\n    target.dataset.trend = up ? \"up\" : \"down\";\n    if (!flash || !counted) return;\n    const good = inverse ? !up : up;\n    const tint = `var(--${good ? \"success\" : \"danger\"})`;\n    for (const a of target.getAnimations()) if (a.id === \"ticker-tint\") a.cancel();\n    // No final keyframe: the color eases back to whatever the text inherits.\n    const anim = target.animate(\n      [\n        { color: tint, offset: 0 },\n        { color: tint, offset: 0.35 },\n      ],\n      { duration: 1400, easing: \"ease-out\" },\n    );\n    anim.id = \"ticker-tint\";\n  }, [shown, flash, inverse, counted]);\n\n  const trend = direction === \"up\" ? 1 : direction === \"down\" ? -1 : undefined;\n\n  return (\n    <span\n      ref={setRefs}\n      data-slot=\"number-ticker\"\n      data-counting={!counted || undefined}\n      className={cn(\"inline-flex whitespace-nowrap tabular\", className)}\n      {...rest}\n    >\n      <NumberFlow\n        value={shown}\n        locales={locale}\n        format={numberFormatOptions({ currency, compact, decimals, format })}\n        prefix={prefix}\n        suffix={suffix}\n        trend={trend}\n        animated={rolls}\n        onAnimationsFinish={() => {\n          if (seen && !finished) setFinished(true);\n        }}\n        {...timing(counted ? 600 : 1100)}\n      />\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/number-ticker"
}
