{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-counter",
  "type": "registry:ui",
  "title": "Animated counter",
  "description": "Give changing totals a clear sense of movement.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/animated-counter/animated-counter.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport interface AnimatedCounterProps {\n  value: number;\n  label?: string;\n  prefix?: string;\n  suffix?: string;\n  decimals?: number;\n  /** Roll every digit up from zero the first time the counter scrolls into view. */\n  animateOnView?: boolean;\n  /** Formatting locale. Fixed by default so server and client render the same digits. */\n  locale?: string;\n}\n\ntype Part = { key: string; digit: number; order: number } | { key: string; text: string };\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst rise: Variants = {\n  hidden: { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` },\n  shown: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n  },\n  gone: {\n    opacity: 0,\n    y: \"-0.3em\",\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n  },\n};\nconst fade: Variants = {\n  hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n};\nconst reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.considered };\n\n/** Split a formatted number into columns keyed by place value, so 999 → 1,000 keeps the ones column the ones column. */\nfunction partsFor(value: number, decimals: number, locale: string): Part[] {\n  const parts = new Intl.NumberFormat(locale, {\n    minimumFractionDigits: decimals,\n    maximumFractionDigits: decimals,\n    numberingSystem: \"latn\",\n  }).formatToParts(value);\n  let place = parts.reduce((count, part) => count + (part.type === \"integer\" ? part.value.length : 0), 0);\n  let fraction = 0;\n  let order = 0;\n  return parts.flatMap((part, index): Part[] => {\n    if (part.type === \"integer\")\n      return Array.from(part.value, (char) => ({ key: `i${--place}`, digit: Number(char), order: order++ }));\n    if (part.type === \"fraction\")\n      return Array.from(part.value, (char) => ({ key: `f${fraction++}`, digit: Number(char), order: order++ }));\n    return [\n      {\n        key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : `${part.type}${index}`,\n        text: part.value,\n      },\n    ];\n  });\n}\n\n/** One digit on the wheel. Its offset from the wheel position decides where it sits and how visible it is. */\nfunction Glyph({ position, digit }: { position: MotionValue<number>; digit: number }) {\n  const offset = useTransform(position, (current) => ((((digit - current) % 10) + 15) % 10) - 5);\n  const y = useTransform(offset, (current) => `${current}em`);\n  const opacity = useTransform(offset, (current) => Math.max(0, 1 - Math.abs(current)));\n  const visibility = useTransform(offset, (current) => (Math.abs(current) >= 1 ? \"hidden\" : \"visible\"));\n  const filter = useTransform(offset, (current) =>\n    Math.abs(current) < 0.02 || Math.abs(current) >= 1\n      ? \"none\"\n      : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`,\n  );\n  return (\n    <motion.span\n      className=\"absolute inset-x-0 inset-y-(--feather) text-center\"\n      style={{ y, opacity, filter, visibility }}\n    >\n      {digit}\n    </motion.span>\n  );\n}\n\nconst presence = {\n  initial: { width: 0, opacity: 0 },\n  animate: { width: \"auto\", opacity: 1 },\n  exit: { width: 0, opacity: 0 },\n};\n\n/** A digit wheel. It always turns in the direction the whole number moved, wrapping 9 → 0 like an odometer.\n *  Its clipped window reaches a little past the line box and feathers out, so a turning digit fades at the edge instead of being cut.\n *  Separators clip sideways only, so a comma keeps its tail. */\nfunction Column({\n  digit,\n  direction,\n  armed,\n  delay,\n  reduceMotion,\n}: {\n  digit: number;\n  direction: number;\n  armed: boolean;\n  delay: number;\n  reduceMotion: boolean;\n}) {\n  const position = useMotionValue(armed ? 0 : digit);\n  const wheel = useRef({ digit: armed ? 0 : digit, target: armed ? 0 : digit, revealed: !armed });\n  useEffect(() => {\n    const state = wheel.current;\n    if (armed || state.digit === digit) {\n      if (!armed) state.revealed = true;\n      return;\n    }\n    state.target +=\n      direction < 0 && state.revealed ? -((state.digit - digit + 10) % 10) : (digit - state.digit + 10) % 10;\n    state.digit = digit;\n    if (reduceMotion) position.jump(state.target);\n    else animate(position, state.target, state.revealed ? motionTokens.spring.smooth : { ...reveal, delay });\n    state.revealed = true;\n  }, [armed, delay, digit, direction, position, reduceMotion]);\n  return (\n    <motion.span\n      className=\"relative inline-block overflow-hidden [--feather:.16em] my-[calc(var(--feather)*-1)] py-(--feather) [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)*1.5),#000_calc(100%_-_var(--feather)*1.5),transparent)]\"\n      {...presence}\n      transition={reduceMotion ? { duration: 0 } : motionTokens.spring.morph}\n    >\n      <span className=\"invisible\">0</span>\n      {DIGITS.map((item) => (\n        <Glyph key={item} position={position} digit={item} />\n      ))}\n    </motion.span>\n  );\n}\n\nexport function AnimatedCounter({\n  value,\n  label,\n  prefix = \"\",\n  suffix = \"\",\n  decimals = 0,\n  animateOnView = false,\n  locale = \"en-US\",\n}: AnimatedCounterProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true, amount: 0.6 });\n  const reduceMotion = !!useReducedMotion();\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(1);\n  if (value !== previous) {\n    setPrevious(value);\n    setDirection(value > previous ? 1 : -1);\n  }\n  const parts = partsFor(value, decimals, locale);\n  const text = `${prefix}${parts.map((part) => (\"text\" in part ? part.text : part.digit)).join(\"\")}${suffix}`;\n  const armed = animateOnView && !inView;\n  return (\n    <span\n      ref={ref}\n      className=\"relative inline-flex flex-col font-display text-3xl leading-none font-medium tracking-display text-ink tabular-nums\"\n    >\n      {label && (\n        <span className=\"mb-2 font-body text-xs leading-body font-normal tracking-body text-ink-3\">\n          <span className=\"relative block\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={label}\n                className=\"inline-block\"\n                variants={reduceMotion ? fade : rise}\n                initial=\"hidden\"\n                animate=\"shown\"\n                exit=\"gone\"\n              >\n                {label}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </span>\n      )}\n      <span className=\"sr-only\">{text}</span>\n      <span className=\"inline-flex items-start whitespace-nowrap select-none\" aria-hidden=\"true\">\n        {prefix && <span className=\"inline-block overflow-x-clip\">{prefix}</span>}\n        <AnimatePresence initial={false}>\n          {parts.map((part) =>\n            \"digit\" in part ? (\n              <Column\n                key={part.key}\n                digit={part.digit}\n                direction={direction}\n                armed={armed}\n                delay={Math.min(part.order * motionTokens.stagger.item, 0.25)}\n                reduceMotion={reduceMotion}\n              />\n            ) : (\n              <motion.span\n                key={part.key}\n                className=\"inline-block overflow-x-clip\"\n                {...presence}\n                transition={reduceMotion ? { duration: 0 } : motionTokens.spring.morph}\n              >\n                {part.text}\n              </motion.span>\n            ),\n          )}\n        </AnimatePresence>\n        {suffix && <span className=\"inline-block overflow-x-clip\">{suffix}</span>}\n      </span>\n    </span>\n  );\n}\n\nexport default AnimatedCounter;\n",
      "type": "registry:component",
      "target": "@components/iris/animated-counter/animated-counter.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/animated-counter",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "AnimatedCounter",
    "tags": [
      "data",
      "motion"
    ],
    "docs": "https://irisui.dev/components/animated-counter"
  }
}
