Animated Counter
A number that counts up when scrolled into view. Respects reduced motion.
0
Installation
npx shadcn@latest add @hazine/animated-counterSource
"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>
)
}