Hazine

Animated Counter

A number that counts up when scrolled into view. Respects reduced motion.

0

Installation

npx shadcn@latest add @hazine/animated-counter

Source

"use client"

import * as React from "react"

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

interface AnimatedCounterProps extends React.ComponentProps<"span"> {
  value: number
  duration?: number
  decimals?: number
  format?: (value: number) => string
}

export function AnimatedCounter({
  value,
  duration = 1200,
  decimals = 0,
  format,
  className,
  ...props
}: AnimatedCounterProps) {
  const ref = React.useRef<HTMLSpanElement>(null)
  const [display, setDisplay] = React.useState(0)
  const [started, setStarted] = React.useState(false)

  React.useEffect(() => {
    const node = ref.current
    if (!node) return
    const observer = new IntersectionObserver(
      ([entry]) => entry.isIntersecting && setStarted(true),
      { threshold: 0.5 }
    )
    observer.observe(node)
    return () => observer.disconnect()
  }, [])

  React.useEffect(() => {
    if (!started) return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setDisplay(value)
      return
    }
    let frame: number
    const start = performance.now()
    const tick = (now: number) => {
      const progress = Math.min((now - start) / duration, 1)
      const eased = 1 - Math.pow(1 - progress, 3)
      setDisplay(value * eased)
      if (progress < 1) frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [started, value, duration])

  const rendered = format
    ? format(display)
    : display.toLocaleString(undefined, {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      })

  return (
    <span
      ref={ref}
      className={cn("tabular-nums", className)}
      {...props}
    >
      {rendered}
    </span>
  )
}