awa-ui 0.3.0-alpha.3

Web UI and JSON API for the Awa job queue
Documentation
"use client"

import { useInView, useMotionValue, useSpring } from "motion/react"
import { useEffect, useRef } from "react"

export interface CountUpProps {
  end: number
  start?: number
  direction?: "up" | "down"
  delay?: number
  duration?: number
  className?: string
  startWhen?: boolean
  separator?: string
  onStart?: () => void
  onEnd?: () => void
  format?: (value: number) => string
  precision?: number
}

export function CountUp({
  end,
  start = 0,
  direction = "up",
  delay = 0,
  duration = 0.1,
  className = "",
  startWhen = true,
  separator = "",
  onStart,
  onEnd,
  format,
  precision,
}: CountUpProps) {
  const ref = useRef<HTMLSpanElement>(null)
  const motionValue = useMotionValue(direction === "down" ? end : start)

  const getDecimalPlaces = (num: number): number => {
    const s = String(num)
    const i = s.indexOf(".")
    return i === -1 ? 0 : s.length - i - 1
  }

  const decimals =
    typeof precision === "number"
      ? Math.max(0, precision)
      : Number.isInteger(start) && Number.isInteger(end)
        ? 0
        : Math.max(getDecimalPlaces(start), getDecimalPlaces(end))

  const damping = 30 + 20 * (1 / Math.max(0.05, duration))
  const stiffness = 140 * (1 / Math.max(0.05, duration))

  const springValue = useSpring(motionValue, {
    damping,
    stiffness,
    restDelta: 0.001,
    restSpeed: 0.001,
  })

  const isInView = useInView(ref, { once: true, margin: "0px" })

  const formatNumber = (n: number) => {
    if (format) return format(n)
    const rounded = Number(n.toFixed(decimals))
    const formatted = new Intl.NumberFormat("en-US", {
      useGrouping: !!separator,
      minimumFractionDigits: decimals,
      maximumFractionDigits: decimals,
    }).format(rounded)
    return separator ? formatted.replace(/,/g, separator) : formatted
  }

  useEffect(() => {
    if (ref.current) {
      ref.current.textContent = formatNumber(direction === "down" ? end : start)
    }
  }, [start, end, direction, decimals, separator, format])

  useEffect(() => {
    if (isInView && startWhen) {
      if (typeof onStart === "function") onStart()
      const t1 = setTimeout(() => {
        motionValue.set(direction === "down" ? start : end)
      }, delay * 1000)
      const t2 = setTimeout(
        () => {
          if (typeof onEnd === "function") onEnd()
        },
        (delay + duration) * 1000,
      )
      return () => {
        clearTimeout(t1)
        clearTimeout(t2)
      }
    }
  }, [isInView, startWhen, motionValue, direction, start, end, delay, onStart, onEnd, duration])

  useEffect(() => {
    const unsub = springValue.on("change", (latest) => {
      if (!ref.current) return
      ref.current.textContent = formatNumber(latest)
    })
    return () => unsub()
  }, [springValue, decimals, separator, format])

  return <span className={className} ref={ref} />
}