{"slug":"number","name":"Number Animation","description":"Animated number primitives for count-up values and rolling digit tickers.","category":"motion","source_url":"https://beui.saura3h.xyz/r/number/raw","detail_url":"https://beui.saura3h.xyz/r/number","raw_url":"https://beui.saura3h.xyz/r/number/raw","page_url":"https://beui.saura3h.xyz/components/motion/number","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/animated-number","@/components/motion/number-ticker","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/animated-number.tsx","type":"component","content":"\"use client\";\n\nimport { animate, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AnimatedNumberProps {\n  value: number;\n  duration?: number;\n  format?: (n: number) => string;\n  className?: string;\n  startOnView?: boolean;\n}\n\nexport function AnimatedNumber({\n  value,\n  duration = 1.2,\n  format = (n) => Math.round(n).toLocaleString(),\n  className,\n  startOnView = true,\n}: AnimatedNumberProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true, amount: 0.6 });\n  const reduce = useReducedMotion();\n  const [display, setDisplay] = useState(0);\n  const fromRef = useRef(0);\n\n  useEffect(() => {\n    if (startOnView && !inView) return;\n    if (reduce) {\n      fromRef.current = value;\n      setDisplay(value);\n      return;\n    }\n    const controls = animate(fromRef.current, value, {\n      duration,\n      ease: EASE_OUT,\n      onUpdate: (v) => setDisplay(v),\n    });\n    fromRef.current = value;\n    return () => controls.stop();\n  }, [value, duration, inView, startOnView, reduce]);\n\n  return (\n    <span ref={ref} className={cn(\"tabular-nums\", className)}>\n      {format(display)}\n    </span>\n  );\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"component","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={reduce ? { duration: 0 } : { duration, delay, ease: EASE_OUT }}\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center\"\n      >\n        {DIGITS.map((n) => (\n          <span key={n} className=\"flex h-[1.1em] items-center justify-center leading-none\">\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/number.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useEffect, useState } from \"react\";\nimport { AnimatedNumber } from \"@/components/motion/animated-number\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\n\nexport function NumberPreview() {\n  const [value, setValue] = useState(48273);\n  const [variant, setVariant] = useState<\"ticker\" | \"animated\">(\"ticker\");\n\n  useEffect(() => {\n    const id = window.setInterval(() => {\n      setValue((currentValue) => currentValue + Math.floor(Math.random() * 50));\n    }, 2500);\n    return () => window.clearInterval(id);\n  }, []);\n\n  useEffect(() => {\n    const id = window.setInterval(() => {\n      setVariant((currentVariant) => currentVariant === \"ticker\" ? \"animated\" : \"ticker\");\n    }, 3000);\n    return () => window.clearInterval(id);\n  }, []);\n\n  return (\n    <div className=\"relative flex min-h-20 min-w-40 items-center justify-center text-center\">\n      <AnimatePresence mode=\"wait\" initial={false}>\n        <motion.div\n          key={variant}\n          initial={{ opacity: 0, filter: \"blur(6px)\", transform: \"translateY(4px)\" }}\n          animate={{ opacity: 1, filter: \"blur(0px)\", transform: \"translateY(0px)\" }}\n          exit={{ opacity: 0, filter: \"blur(6px)\", transform: \"translateY(-4px)\" }}\n          transition={{ duration: 0.22, ease: EASE_OUT }}\n        >\n          {variant === \"ticker\" ? (\n            <div>\n              <p className=\"text-xs text-(--color-fg-muted)\">Active users</p>\n              <NumberTicker\n                value={value}\n                className=\"text-3xl font-semibold tracking-tight text-(--color-fg) tabular-nums\"\n                format={(number) => number.toLocaleString()}\n              />\n            </div>\n          ) : (\n            <div>\n              <p className=\"text-xs text-(--color-fg-muted)\">Revenue</p>\n              <div className=\"text-3xl font-semibold tracking-tight text-(--color-fg) tabular-nums\">\n                <AnimatedNumber\n                  value={129480}\n                  format={(number) => `$${Math.round(number).toLocaleString()}`}\n                />\n              </div>\n            </div>\n          )}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n"}]}