{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/animated-counter/animated-counter.tsx",
      "type": "registry:component",
      "target": "@components/xzero/animated-counter/animated-counter.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type MotionValue, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./animated-counter.module.css\";\n\nexport interface AnimatedCounterProps {\n  value: number; label?: string; prefix?: string; suffix?: string; 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 = { hidden: { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } } };\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\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, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: \"latn\" }).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\") return [...part.value].map(char => ({ key: `i${--place}`, digit: Number(char), order: order++ }));\n    if (part.type === \"fraction\") return [...part.value].map(char => ({ key: `f${fraction++}`, digit: Number(char), order: order++ }));\n    return [{ key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : `${part.type}${index}`, text: part.value }];\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 => Math.abs(current) < .02 || Math.abs(current) >= 1 ? \"none\" : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`);\n  return <motion.span className={styles.glyph} style={{ y, opacity, filter, visibility }}>{digit}</motion.span>;\n}\n\nconst presence = { initial: { width: 0, opacity: 0 }, animate: { width: \"auto\", opacity: 1 }, exit: { width: 0, opacity: 0 } };\n\n/** A digit wheel. It always turns in the direction the whole number moved, wrapping 9 → 0 like an odometer. */\nfunction Column({ digit, direction, armed, delay, reduceMotion }: { digit: number; direction: number; armed: boolean; delay: number; reduceMotion: boolean }) {\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) { if (!armed) state.revealed = true; return; }\n    state.target += 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 <motion.span className={styles.column} {...presence} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.morph}>\n    <span className={styles.sizer}>0</span>\n    {DIGITS.map(item => <Glyph key={item} position={position} digit={item} />)}\n  </motion.span>;\n}\n\nexport function AnimatedCounter({ value, label, prefix = \"\", suffix = \"\", decimals = 0, animateOnView = false, locale = \"en-US\" }: AnimatedCounterProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true, amount: .6 });\n  const reduceMotion = !!useReducedMotion();\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(1);\n  if (value !== previous) { setPrevious(value); setDirection(value > previous ? 1 : -1); }\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 <span ref={ref} className={styles.counter}>\n    {label && <span className={styles.label}><span className={styles.labelSwap}><AnimatePresence mode=\"popLayout\" initial={false}><motion.span key={label} className={styles.labelText} variants={reduceMotion ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{label}</motion.span></AnimatePresence></span></span>}\n    <span className={styles.srOnly}>{text}</span>\n    <span className={styles.value} aria-hidden=\"true\">\n      {prefix && <span className={styles.symbol}>{prefix}</span>}\n      <AnimatePresence initial={false}>\n        {parts.map(part => \"digit\" in part\n          ? <Column key={part.key} digit={part.digit} direction={direction} armed={armed} delay={Math.min(part.order * motionTokens.stagger.item, .25)} reduceMotion={reduceMotion} />\n          : <motion.span key={part.key} className={styles.symbol} {...presence} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.morph}>{part.text}</motion.span>)}\n      </AnimatePresence>\n      {suffix && <span className={styles.symbol}>{suffix}</span>}\n    </span>\n  </span>;\n}\n\nexport default AnimatedCounter;\n"
    },
    {
      "path": "registry/components/animated-counter/animated-counter.module.css",
      "type": "registry:component",
      "target": "@components/xzero/animated-counter/animated-counter.module.css",
      "content": ".counter { position: relative; display: inline-flex; flex-direction: column; color: var(--foreground); font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; }\n.label { margin-bottom: var(--space-2); color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); font-weight: 400; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n/* Each digit is a clipped wheel; separators clip sideways only so a comma keeps its tail. */\n.value { display: inline-flex; align-items: flex-start; white-space: nowrap; user-select: none; }\n/* The wheel's window reaches a little past the line box and feathers out, so a turning digit fades at the edge instead of being cut. */\n.column { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }\n.sizer { visibility: hidden; }\n.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }\n.labelText { display: inline-block; }\n.labelSwap { position: relative; display: block; }\n.symbol { display: inline-block; overflow-x: clip; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/animated-counter",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "motion"
    ],
    "docs": "https://ui.xzero.co.uk/components/animated-counter",
    "markdown": "https://ui.xzero.co.uk/components/animated-counter/markdown"
  }
}