counter-number

PreviousNext
Docs
aliimamcomponent

Preview

Loading preview…
registry/default/components/counter-number.tsx
"use client"

import {
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
} from "react"

import { cn } from "@/registry/default/lib/utils"

type SizeVariant = "sm" | "md" | "lg" | "xl" | "2xl"
type ColorVariant =
  | "default"
  | "primary"
  | "secondary"
  | "success"
  | "warning"
  | "error"

interface CounterNumberProps extends ComponentPropsWithoutRef<"span"> {
  value: number
  startValue?: number
  duration?: number // new: animation duration in ms

  decimalPlaces?: number
  prefix?: string
  suffix?: string
  separator?: string
  currency?: string
  locale?: string

  size?: SizeVariant
  color?: ColorVariant
  preserveAspectRatio?: boolean
}

const sizeClasses: Record<SizeVariant, string> = {
  sm: "text-md",
  md: "text-xl",
  lg: "text-3xl",
  xl: "text-5xl",
  "2xl": "text-7xl",
}

const colorClasses: Record<ColorVariant, string> = {
  default: "text-foreground",
  primary: "text-blue-600 dark:text-blue-400",
  secondary: "text-gray-600 dark:text-gray-400",
  success: "text-green-600 dark:text-green-400",
  warning: "text-yellow-600 dark:text-yellow-400",
  error: "text-red-600 dark:text-red-400",
}

export function CounterNumber({
  value,
  startValue = 0,
  duration = 1000,
  decimalPlaces = 0,
  prefix = "",
  suffix = "",
  separator = ",",
  currency,
  locale = "en-US",
  size = "md",
  color = "default",
  preserveAspectRatio = false,
  className,
  ...props
}: CounterNumberProps) {
  const ref = useRef<HTMLSpanElement>(null)
  const [displayValue, setDisplayValue] = useState(startValue)

  useEffect(() => {
    let startTime: number | null = null
    const start = displayValue
    const end = value
    const diff = end - start

    const step = (timestamp: number) => {
      if (!startTime) startTime = timestamp
      const progress = Math.min((timestamp - startTime) / duration, 1)
      setDisplayValue(start + diff * progress)
      if (progress < 1) {
        requestAnimationFrame(step)
      }
    }

    requestAnimationFrame(step)
  }, [value, duration]) // animate whenever value or duration changes

  const formatNumber = (numValue: number): string => {
    let formattedValue: string

    if (currency) {
      formattedValue = new Intl.NumberFormat(locale, {
        style: "currency",
        currency: currency,
        minimumFractionDigits: decimalPlaces,
        maximumFractionDigits: decimalPlaces,
      }).format(numValue)
    } else {
      formattedValue = new Intl.NumberFormat(locale, {
        minimumFractionDigits: decimalPlaces,
        maximumFractionDigits: decimalPlaces,
      }).format(numValue)

      if (separator !== ",") {
        formattedValue = formattedValue.replace(/,/g, separator)
      }
    }

    return `${prefix}${formattedValue}${suffix}`
  }

  const combinedClassName = cn(
    "inline-block tabular-nums tracking-wider transition-all",
    sizeClasses[size],
    colorClasses[color],
    preserveAspectRatio && "font-mono",
    className
  )

  return (
    <span ref={ref} className={combinedClassName} {...props}>
      {formatNumber(displayValue)}
    </span>
  )
}

Installation

npx shadcn@latest add @aliimam/counter-number

Usage

import { CounterNumber } from "@/components/counter-number"
<CounterNumber />