{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"animated-counter","type":"registry:ui","title":"Animated counter","description":"A number that counts to its value, with prefix, suffix and locale.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/animated-counter.tsx","type":"registry:ui","target":"components/ui/animated-counter.tsx","content":"\"use client\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { cssMs } from \"./lib/motion-tokens\";\nimport \"./animated-counter.css\";\nfunction CounterDigit({\n  digit,\n  direction,\n  reveal,\n}: {\n  digit: number;\n  direction: \"up\" | \"down\" | \"none\";\n  reveal: boolean;\n}) {\n  const column = useRef<HTMLSpanElement>(null),\n    first = useRef(true);\n  useLayoutEffect(() => {\n    const element = column.current;\n    if (!element) return;\n    const animate = (initial = false) => {\n      const tokens = getComputedStyle(element),\n        height = element.parentElement!.getBoundingClientRect().height;\n      const transform = tokens.transform;\n      const offset =\n        transform === \"none\"\n          ? 20 + digit\n          : -new DOMMatrixReadOnly(transform).m42 / height;\n      const start = initial ? 20 : 20 + (((offset % 10) + 10) % 10);\n      let end = 20 + digit;\n      if (direction === \"up\" && end < start) end += 10;\n      if (direction === \"down\" && end > start) end -= 10;\n      element.getAnimations().forEach((animation) => animation.cancel());\n      element.style.transform = `translateY(-${(20 + digit) * 1.2}em)`;\n      if (\n        !matchMedia(\"(prefers-reduced-motion: reduce)\").matches &&\n        (initial || direction !== \"none\")\n      )\n        element.animate(\n          [\n            { transform: `translateY(-${start * 1.2}em)` },\n            { transform: `translateY(-${end * 1.2}em)` },\n          ],\n          {\n            duration: cssMs(tokens.getPropertyValue(\"--duration-deliberate\")),\n            easing: tokens.getPropertyValue(\"--ease-out\").trim(),\n          },\n        );\n    };\n    if (first.current) {\n      first.current = false;\n      element.style.transform = `translateY(-${(20 + digit) * 1.2}em)`;\n      if (reveal) {\n        const observer = new IntersectionObserver(\n          (entries) => {\n            if (entries[0].isIntersecting) {\n              animate(true);\n              observer.disconnect();\n            }\n          },\n          { threshold: 0.6 },\n        );\n        observer.observe(element.parentElement!);\n        return () => observer.disconnect();\n      }\n    } else animate();\n  }, [digit, direction, reveal]);\n  useEffect(() => {\n    const preference = matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const cancel = () => {\n      if (preference.matches)\n        column.current\n          ?.getAnimations()\n          .forEach((animation) => animation.cancel());\n    };\n    preference.addEventListener(\"change\", cancel);\n    return () => preference.removeEventListener(\"change\", cancel);\n  }, []);\n  return (\n    <span\n      data-slot=\"counter-digit\"\n      className=\"inline-block h-[1.2em] w-[1ch] overflow-hidden\"\n    >\n      <span\n        ref={column}\n        className=\"block\"\n        style={\n          {\n            transform: \"translateY(calc(-1.2em * var(--counter-target)))\",\n            \"--counter-target\": 20 + digit,\n          } as CSSProperties\n        }\n      >\n        {Array.from({ length: 50 }, (_, i) => (\n          <span key={i} className=\"block h-[1.2em] leading-[1.2]\">\n            {i % 10}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\nexport function AnimatedCounter({\n  value,\n  label,\n  prefix = \"\",\n  suffix = \"\",\n  decimals = 0,\n  locale = \"en-US\",\n  animateOnView = false,\n  className,\n}: {\n  value: number;\n  label?: string;\n  prefix?: string;\n  suffix?: string;\n  decimals?: number;\n  locale?: string;\n  animateOnView?: boolean;\n  className?: string;\n}) {\n  const shell = useRef<HTMLSpanElement>(null),\n    content = useRef<HTMLSpanElement>(null);\n  useLayoutEffect(() => {\n    const measure = () => {\n      if (shell.current && content.current)\n        shell.current.style.width = `${content.current.offsetWidth}px`;\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (content.current) observer.observe(content.current);\n    return () => observer.disconnect();\n  }, []);\n  const [frame, setFrame] = useState({ value, prior: value });\n  if (!Object.is(frame.value, value)) setFrame({ value, prior: frame.value });\n  const direction =\n    value > frame.prior ? \"up\" : value < frame.prior ? \"down\" : \"none\";\n  const text = Number.isFinite(value)\n    ? new Intl.NumberFormat(locale, {\n        minimumFractionDigits: Math.max(0, Math.min(20, decimals)),\n        maximumFractionDigits: Math.max(0, Math.min(20, decimals)),\n      }).format(value)\n    : \"—\";\n  return (\n    <span\n      data-slot=\"animated-counter\"\n      className={cn(\"inline-flex flex-col min-w-0\", className)}\n    >\n      {label && (\n        <span className=\"mb-1 text-xs font-medium text-muted-foreground\">\n          {label}\n        </span>\n      )}\n      <span className=\"sr-only\">\n        {prefix}\n        {text}\n        {suffix}\n      </span>\n      <span\n        ref={shell}\n        data-slot=\"counter-shell\"\n        aria-hidden=\"true\"\n        className=\"relative inline-block h-[1.2em] overflow-hidden\"\n      >\n        <span\n          ref={content}\n          className=\"absolute right-0 inline-flex w-max items-center tabular-nums\"\n        >\n          {prefix}\n          {[...text].map((glyph, index) =>\n            /\\d/.test(glyph) ? (\n              <CounterDigit\n                key={`digit-${text.length - index}`}\n                digit={Number(glyph)}\n                direction={direction}\n                reveal={animateOnView}\n              />\n            ) : (\n              <span key={`separator-${text.length - index}`}>{glyph}</span>\n            ),\n          )}\n          {suffix}\n        </span>\n      </span>\n    </span>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/motion-tokens.ts","type":"registry:ui","target":"components/ui/lib/motion-tokens.ts","content":"/** Milliseconds from a CSS time such as \"280ms\" or \".28s\" (build tools may rewrite one as the other). */\nexport function cssMs(value: string) {\n  const text = value.trim();\n  const number = parseFloat(text);\n  if (!Number.isFinite(number)) return 0;\n  return text.endsWith(\"ms\") ? number : text.endsWith(\"s\") ? number * 1000 : number;\n}\n\n/** Reads a duration token (e.g. \"--duration-travel\") in milliseconds from an element; the tokens resolve to 0 under reduced motion. */\nexport function tokenMs(element: Element, name: string) {\n  return cssMs(getComputedStyle(element).getPropertyValue(name));\n}\n"},{"path":"ui/animated-counter.css","type":"registry:file","target":"components/ui/animated-counter.css","content":"/* animated-counter.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"counter-shell\"] {\n    transition: width var(--duration-deliberate) var(--ease-out);\n  }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"counter-shell\"] {\n      transition: none;\n    }\n  }\n}\n"}],"categories":["charts"],"meta":{"tier":"free","license":"MIT","family":"core-charts","docs":"/docs/components/animated-counter"}}