{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-number",
  "title": "Animated Number",
  "description": "A number that counts to its value instead of appearing at it, for a figure meant to be looked at: a dashboard KPI or metric tile, a results, votes, signups, downloads, streams or follower count, a pricing page's savings or annual-total figure, a fundraising or goal tracker, a score, streak or XP total, a usage or quota readout, and the statistic in a marketing hero that should count as it scrolls into view. Common asks it answers: \"animated counter react\", \"count up animation react\", \"number ticker component\", \"animated number shadcn\", \"shadcn counter\", \"react-countup alternative\", \"odometer react\", \"rolling number animation\", \"kpi counter animation\", \"count up on scroll react\", \"animate number when it changes react\", \"intersection observer counter react\", \"useCountUp hook\", \"animated number currency format\", \"animate a formatted number react\", \"counter jitters while animating\", \"number width jumps while counting up\", \"tabular nums counter\", \"prefers-reduced-motion counter\", \"animated number without framer motion\", \"数字 カウントアップ react\", \"数値 アニメーション\", \"カウントアップ アニメーション\", \"スクロールで数字を数え上げる\", \"桁が揺れる カウンター\". Official shadcn/ui has nothing of the kind, and the figure is measured rather than remembered: its catalogue today lists 65 entries of which 62 are fetchable (code-block, questionnaire and trace 404) for 223,287 bytes of component source, and in all of it requestAnimationFrame, IntersectionObserver, NumberFormat, prefers-reduced-motion and countUp are every one of them zero hits. tabular-nums appears exactly twice -- on a chart tooltip's value and a sidebar badge's count -- and both of those numbers sit still, so nothing in official has ever had to make a number move. An agent asked for this therefore writes the requestAnimationFrame loop from scratch, and the loop it writes makes the line the number sits on move. That is the whole reason to install one. Interpolating towards 12,480 renders 1,204 and then 11,593 on the way, and in a proportional font a 1 is not as wide as an 8, so the number changes width every frame and drags the label beside it along; then the group separator arrives at 1,000 and it jumps again. tabular-nums fixes the digits, but the separator and the digit count are not fixed by anything, so the widest string the run can produce is rendered invisibly underneath in the same grid cell and holds the width open from the first frame. That widest string is found by formatting the points where the width can actually change -- each power of ten and the all-nines value below it -- rather than by formatting the two ends, because the ends are not the widest: counting 0 to 1000 in compact notation finishes at \"1K\" and passes through \"999\", and under scientific notation it passes through \"9.99E2\" where both ends are three characters. It is measured over the declared journey rather than over the live value, too, because re-measuring every frame narrows the box while counting down -- the box would shrink under the number it is holding open, which is the shift it exists to prevent. Four more things separate it from the twenty-line version. It interpolates the number and formats each frame, rather than interpolating the formatted string, so a currency symbol, a locale's group separators and a percent sign all survive the trip -- and the decimals are pinned to what the endpoints need, because Intl.NumberFormat's default of up to three fraction digits renders 1,203.882 then 1,229.44 then 1,234.5 on the way to a value whose own length never changes. A float's tail is read as the precision it meant rather than literally, so 0.1 + 0.2 counts to 0.3 and not to 0.30000000000000004, while anything the caller says about digits -- fraction or significant, currency, percent or compact -- is left exactly as given, since fraction digits can outrank significant ones under morePrecision. prefers-reduced-motion means the value simply appears, and it is read from the media query at the moment the decision is made rather than from state, because state is a render behind and a flag that arrives in a passive effect animates one frame for somebody who asked for none; the preference is also subscribed to, so turning it on mid-count finishes the count. A target that changes while the number is still moving is picked up from wherever the number currently is instead of restarting at zero, which is the ordinary case on a dashboard that polls. And the first render is the real number, on the server and the client alike, so the HTML a crawler or a page with JavaScript turned off receives says 12,480 -- the starting value is put in place before the first paint, so the final number is never shown and then counted up to. It is accessible in the one way that matters here: the counting digits are hidden from the accessibility tree and the target is exposed as text, because a number announced sixty times a second is not a reading of a number. Set startOnView to hold at the starting value until the element has been scrolled to, which counts once and then stops observing; a browser without IntersectionObserver counts immediately rather than sitting at zero forever, and one without requestAnimationFrame shows the value, which is also what the server does. useAnimatedNumber() is exported for a figure of your own design, handing back the current frame, the formatted target and the width to hold open; easing is a plain function so an inline one costs nothing (it is read live rather than listed as a dependency, which is what stops a run restarting on every render it causes); onComplete reports each arrival once per target. Within pulld it is the counterpart to countdown, which moves towards a moment in time rather than a value, and it is the thing to put inside stat-card, where the figure lives; number-input collects a number, gauge, ratio-bar and sparkline draw one as a shape, and this one moves the digits themselves. Depends only on your cn util -- no animation library, no extra packages.",
  "files": [
    {
      "path": "registry/ui/animated-number.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\"\n\n/**\n * How many decimals are derived from the values themselves before giving up.\n *\n * A number arriving from arithmetic rather than from a person carries the float's whole tail —\n * `String(0.1 + 0.2)` is \"0.30000000000000004\" — and pinning seventeen decimals because of it\n * would be worse than pinning none. Past this, pass `format` and say what the number is.\n */\nconst MAX_DERIVED_DECIMALS = 4\n\n/** Powers of ten considered when working out how wide the number can get. */\nconst MAX_DECADE = 21\n\nconst easeOutCubic = (t: number) => 1 - (1 - t) ** 3\n\nconst clock = () =>\n  typeof performance !== \"undefined\" && typeof performance.now === \"function\"\n    ? performance.now()\n    : Date.now()\n\n/**\n * Read live rather than held in state, because state is a render behind.\n *\n * The first client render has to agree with the server's HTML, so a reduced-motion flag kept in\n * state is necessarily `false` on that render and only becomes true in a passive effect — after\n * the paint. Anything deciding \"animate or jump\" from that flag animates one frame for somebody\n * who asked not to see it. The media query itself has no such delay.\n */\nfunction prefersReducedMotion(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false\n  return window.matchMedia(REDUCED_MOTION).matches\n}\n\nfunction decimalsOf(n: number): number {\n  if (!Number.isFinite(n)) return 0\n  // Rounded to the cap first and then trimmed, so a float's tail is read as the one decimal the\n  // caller meant: 0.1 + 0.2 is seventeen decimals as a string and one decimal as a number.\n  const text = Math.abs(n).toFixed(MAX_DERIVED_DECIMALS).replace(/0+$/, \"\")\n  const point = text.indexOf(\".\")\n  if (point === -1) return 0\n  return text.length - point - 1\n}\n\n/**\n * Pins the number of decimals when the caller has not, so the digits after the point stop moving.\n *\n * Counting to 1234.5 passes through 1203.8817 and 1229.44, and `Intl.NumberFormat`'s default is up\n * to three decimals — so the default renders \"1,203.882\", then \"1,229.44\", then \"1,234.5\", and the\n * number changes length three times on its way to a value whose length never changes. Deriving one\n * decimal from the target and holding it there is the whole fix.\n *\n * Left alone when the caller has said anything about digits themselves, and for the styles that\n * already answer the question: currency pins decimals per currency, percent defaults to none\n * (and its digits belong to the percentage, not to the fraction it came from), and compact\n * notation's \"1.2K\" is a deliberate single decimal that pinning zero would flatten to \"1K\".\n */\nfunction resolveFormat(\n  format: Intl.NumberFormatOptions | undefined,\n  from: number,\n  to: number\n): Intl.NumberFormatOptions {\n  const given = format ?? {}\n  if (\n    given.minimumFractionDigits !== undefined ||\n    given.maximumFractionDigits !== undefined ||\n    given.minimumSignificantDigits !== undefined ||\n    given.maximumSignificantDigits !== undefined ||\n    given.style === \"currency\" ||\n    given.style === \"percent\" ||\n    (given.notation !== undefined && given.notation !== \"standard\")\n  ) {\n    return given\n  }\n  const digits = Math.max(decimalsOf(from), decimalsOf(to))\n  return { ...given, minimumFractionDigits: digits, maximumFractionDigits: digits }\n}\n\n/**\n * The values along the path where the formatted width can change.\n *\n * Only the integer digit count moves it, and that moves at each power of ten and at the all-nines\n * value just below one, so those are the only interior points worth formatting. The endpoints are\n * not enough on their own: counting 0 to 1000 under compact notation ends at \"1K\" but passes\n * through \"999\", which is wider than where it finishes.\n */\nfunction widthCandidates(from: number, to: number): number[] {\n  const lo = Math.min(from, to)\n  const hi = Math.max(from, to)\n  const out = [lo, hi]\n  for (let k = 0; k <= MAX_DECADE; k++) {\n    const decade = 10 ** k\n    for (const candidate of [decade, decade - 1, -decade, -(decade - 1)]) {\n      if (candidate > lo && candidate < hi) out.push(candidate)\n    }\n  }\n  return out\n}\n\n/**\n * The widest string these values format to, scored by digit count and then by total length.\n *\n * Digits are what `tabular-nums` makes equal and what therefore dominates the width; a group\n * separator or a currency symbol is narrower than a digit, so \"1,000\" is wider than \"999\" on four\n * digits against three even though both strings are measured in characters. Counting with the\n * Unicode decimal property rather than `\\d` keeps this true in a locale whose numbering system is\n * not Latin.\n */\nfunction widestOf(formatter: Intl.NumberFormat, values: number[]): string {\n  let widest = \"\"\n  let bestDigits = -1\n  let bestLength = -1\n  for (const value of values) {\n    if (!Number.isFinite(value)) continue\n    const text = formatter.format(value)\n    const digits = (text.match(/\\p{Nd}/gu) ?? []).length\n    if (digits > bestDigits || (digits === bestDigits && text.length > bestLength)) {\n      widest = text\n      bestDigits = digits\n      bestLength = text.length\n    }\n  }\n  return widest\n}\n\n/**\n * `useLayoutEffect` on the client, `useEffect` on the server, where the layout variant does nothing\n * and says so in a warning. Running before the paint is what keeps the final value out of the\n * first frame: the server's HTML carries the real number, and the client replaces it with the\n * starting value without ever showing it.\n */\nconst useBeforePaint =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport interface UseAnimatedNumberOptions {\n  /** Where the number should end up. Changing it counts from wherever the last run got to. */\n  value: number\n  /** Where the first run starts (default 0). Ignored afterwards. */\n  from?: number\n  /** Milliseconds for one run (default 800). Zero or less means no animation at all. */\n  duration?: number\n  /** Progress curve, from (0, 0) to (1, 1). Default is ease-out cubic. */\n  easing?: (t: number) => number\n  /** Passed straight to `Intl.NumberFormat`, so the caller owns locale and currency. */\n  locale?: string | string[]\n  format?: Intl.NumberFormatOptions\n  /** Hold until the element has been scrolled into view, then count once (default false). */\n  startOnView?: boolean\n  /** How much of the element has to be visible for `startOnView` (default 0). */\n  threshold?: number\n  /** Count on the first render (default true). With false, the first value simply appears. */\n  animateOnMount?: boolean\n  /** Fired once per target, when the number arrives at it. */\n  onComplete?: () => void\n}\n\nexport interface AnimatedNumberState<T extends HTMLElement = HTMLElement> {\n  /** The number as it stands this frame, interpolated and not yet formatted. */\n  value: number\n  /** `value` through `Intl.NumberFormat` — what to show. */\n  text: string\n  /** The target through the same formatter — what to tell a screen reader. */\n  finalText: string\n  /** The widest string this run can produce. Render it invisibly to hold the width open. */\n  reserveText: string\n  isAnimating: boolean\n  /** Attach to the element that has to come into view before `startOnView` starts counting. */\n  ref: React.RefObject<T | null>\n}\n\n/**\n * The counting itself, for a number that has to live inside markup of your own.\n *\n * Returns the formatted frame, the formatted target, and the widest string the run can produce;\n * the component below is these three in a stacked grid, and nothing else.\n */\nexport function useAnimatedNumber<T extends HTMLElement = HTMLElement>({\n  value,\n  from = 0,\n  duration = 800,\n  easing = easeOutCubic,\n  locale,\n  format,\n  startOnView = false,\n  threshold = 0,\n  animateOnMount = true,\n  onComplete,\n}: UseAnimatedNumberOptions): AnimatedNumberState<T> {\n  const ref = React.useRef<T | null>(null)\n\n  const resolvedFormat = resolveFormat(format, from, value)\n  // Keyed on what the options say rather than on the object they arrived in: `format={{ ... }}` is\n  // a new object every render, so keying on its identity rebuilds the formatter every frame and\n  // re-runs everything downstream of it.\n  const formatKey = JSON.stringify([locale ?? null, resolvedFormat])\n  const formatter = React.useMemo(\n    () => new Intl.NumberFormat(locale, resolvedFormat),\n    [formatKey] // eslint-disable-line react-hooks/exhaustive-deps\n  )\n\n  const [display, setDisplay] = React.useState<number | null>(null)\n  const [started, setStarted] = React.useState(!startOnView)\n  // Bumped when the reduced-motion preference changes, purely so the run below re-evaluates it.\n  const [motionEpoch, setMotionEpoch] = React.useState(0)\n\n  // What is on screen this instant. A ref rather than state because the next run reads it to know\n  // where to count from, and it has to be right during the effect that starts that run — not one\n  // render later.\n  const currentRef = React.useRef<number | null>(null)\n  // Which target onComplete has already fired for, so re-running the effect for any other reason\n  // cannot report the same arrival twice.\n  const firedForRef = React.useRef<number | null>(null)\n  const hasRunRef = React.useRef(false)\n  const hasPrimedRef = React.useRef(false)\n\n  const onCompleteRef = React.useRef(onComplete)\n  const easingRef = React.useRef(easing)\n  React.useEffect(() => {\n    onCompleteRef.current = onComplete\n    // Held in a ref and deliberately not a dependency of the run below. `easing={(t) => t}` is a\n    // new function on every render, and an animation that restarts whenever its easing changes\n    // identity restarts on every frame it causes — it would approach the target and never reach it.\n    easingRef.current = easing\n  })\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return\n    const query = window.matchMedia(REDUCED_MOTION)\n    if (typeof query.addEventListener !== \"function\") return\n    const onChange = () => setMotionEpoch((epoch) => epoch + 1)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  React.useEffect(() => {\n    if (!startOnView) {\n      setStarted(true)\n      return\n    }\n    const element = ref.current\n    // No element to watch, or an environment without the observer: count rather than sit at the\n    // starting value forever.\n    if (!element || typeof IntersectionObserver !== \"function\") {\n      setStarted(true)\n      return\n    }\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          setStarted(true)\n          observer.disconnect()\n        }\n      },\n      { threshold }\n    )\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [startOnView, threshold])\n\n  // Move to the starting value before the first paint. Rendering `display ?? value` means the\n  // server's HTML and the first client render both carry the real number — which is what a crawler,\n  // a feed reader and a page with JavaScript turned off should get — and this replaces it with the\n  // starting value in the same commit, so the number is never painted twice.\n  useBeforePaint(() => {\n    // Guarded by a ref rather than by the empty dependency list alone, because \"once\" has to mean\n    // once however the effect is run again: StrictMode mounts, unmounts and remounts every effect\n    // in development, and a second priming would drag a number that had already counted back to\n    // the bottom and count it again.\n    if (hasPrimedRef.current) return\n    hasPrimedRef.current = true\n    if (!animateOnMount && !startOnView) return\n    if (!Number.isFinite(value) || !Number.isFinite(from) || from === value) return\n    if (prefersReducedMotion()) return\n    currentRef.current = from\n    setDisplay(from)\n    // Mount only: this is about the first paint, and `from` is ignored after the first run.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useBeforePaint(() => {\n    if (!started) return\n\n    const arrive = () => {\n      currentRef.current = value\n      setDisplay(value)\n      if (firedForRef.current !== value) {\n        firedForRef.current = value\n        onCompleteRef.current?.()\n      }\n    }\n\n    const isFirstRun = !hasRunRef.current\n    hasRunRef.current = true\n\n    // Not a number to count towards. Show it as it is — `Intl.NumberFormat` renders NaN as \"NaN\" —\n    // rather than interpolating towards it and never arriving.\n    if (!Number.isFinite(value)) {\n      currentRef.current = value\n      setDisplay(value)\n      return\n    }\n    if (isFirstRun && !animateOnMount && !startOnView) {\n      arrive()\n      return\n    }\n    if (\n      prefersReducedMotion() ||\n      duration <= 0 ||\n      typeof requestAnimationFrame !== \"function\"\n    ) {\n      arrive()\n      return\n    }\n\n    const start = currentRef.current ?? from\n    if (!Number.isFinite(start) || start === value) {\n      arrive()\n      return\n    }\n\n    const began = clock()\n    let frame: number | null = requestAnimationFrame(step)\n\n    function step() {\n      const elapsed = clock() - began\n      if (elapsed >= duration) {\n        frame = null\n        arrive()\n        return\n      }\n      const eased = easingRef.current(elapsed / duration)\n      currentRef.current = start + (value - start) * eased\n      setDisplay(currentRef.current)\n      frame = requestAnimationFrame(step)\n    }\n\n    return () => {\n      if (frame !== null) cancelAnimationFrame(frame)\n    }\n  }, [value, started, duration, from, animateOnMount, startOnView, motionEpoch])\n\n  const current = display ?? value\n  // Measured over the declared journey rather than over where the number is this instant. Reading\n  // the live value here would re-measure every frame, and counting *down* then narrows the range\n  // frame by frame — the box would shrink under the number it is holding open, which is the exact\n  // shift this component exists to prevent. Pinned to the endpoints, the width changes only when\n  // the target does.\n  const reserveText = React.useMemo(\n    () =>\n      widestOf(\n        formatter,\n        widthCandidates(Number.isFinite(from) ? from : value, value)\n      ),\n    [formatter, from, value]\n  )\n\n  return {\n    value: current,\n    text: formatter.format(current),\n    finalText: formatter.format(value),\n    reserveText,\n    isAnimating: display !== null && display !== value,\n    ref,\n  }\n}\n\nexport interface AnimatedNumberProps\n  extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\">,\n    UseAnimatedNumberOptions {\n  /** Which edge the number sits against inside the width held open for it (default \"start\"). */\n  align?: \"start\" | \"end\"\n}\n\n/**\n * A number that counts to its value instead of appearing at it — dashboard KPIs, a pricing page's\n * savings figure, a results or vote count, a stat card's figure.\n *\n * The reason to install one rather than write a `requestAnimationFrame` loop is that the obvious\n * version makes the line it sits on move. Interpolating towards 12,480 renders 1,204 and then\n * 11,593 and then 12,480, and in a proportional font the digits are not the same width, so the\n * number changes width every frame and drags the label beside it along; then the group separator\n * arrives at 1,000 and it jumps again. `tabular-nums` fixes the digits, and the widest value the\n * run can produce is rendered invisibly underneath to hold the rest of the width open from the\n * first frame.\n *\n * It interpolates the number and formats each frame, rather than interpolating the formatted\n * string, so a currency symbol and group separators survive the trip; `prefers-reduced-motion`\n * means the value simply appears; a target that changes mid-count is picked up from wherever the\n * number currently is rather than restarting at zero; and only the final value is exposed to a\n * screen reader, because sixty announcements a second is not a reading of a number.\n */\nexport const AnimatedNumber = React.forwardRef<HTMLSpanElement, AnimatedNumberProps>(\n  function AnimatedNumber(\n    {\n      className,\n      value,\n      from,\n      duration,\n      easing,\n      locale,\n      format,\n      startOnView,\n      threshold,\n      animateOnMount,\n      onComplete,\n      align = \"start\",\n      ...props\n    },\n    forwardedRef\n  ) {\n    const { text, finalText, reserveText, ref } = useAnimatedNumber<HTMLSpanElement>({\n      value,\n      from,\n      duration,\n      easing,\n      locale,\n      format,\n      startOnView,\n      threshold,\n      animateOnMount,\n      onComplete,\n    })\n\n    React.useImperativeHandle(forwardedRef, () => ref.current as HTMLSpanElement, [ref])\n\n    return (\n      <span\n        ref={ref}\n        className={cn(\"inline-grid tabular-nums\", className)}\n        {...props}\n      >\n        {/* Holds the width open. `invisible` keeps it out of the accessibility tree as well as out\n            of the picture, so it is the live value's sibling rather than a second number. */}\n        <span aria-hidden=\"true\" className=\"invisible col-start-1 row-start-1\">\n          {reserveText}\n        </span>\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"col-start-1 row-start-1\",\n            align === \"end\" ? \"justify-self-end\" : \"justify-self-start\"\n          )}\n        >\n          {text}\n        </span>\n        <span className=\"sr-only\">{finalText}</span>\n      </span>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
