AnimatedCounter.tsx139 lines · main
1'use client'
2
3import { animate, motion, useInView, useMotionValue, useTransform } from 'framer-motion'
4import React, { FC, useEffect, useRef } from 'react'
5
6import { cn } from '../../lib/utils/cn'
7
8export interface AnimatedCounterProps {
9 /**
10 * The target value to animate to
11 */
12 value: number
13 /**
14 * Animation duration in seconds
15 * @default 2.5
16 */
17 duration?: number
18 /**
19 * Animation delay in seconds
20 * @default 0.25
21 */
22 delay?: number
23 /**
24 * Whether the value represents a percentage
25 * @default false
26 */
27 isPercentage?: boolean
28 /**
29 * Show a prefix before the value, useful for percentages or negative values
30 * @default undefined
31 */
32 // showPlus?: boolean
33 prefix?: string
34 /**
35 * Additional CSS classes to apply
36 */
37 className?: string
38 /**
39 * Animation easing function
40 * @default 'linear'
41 */
42 ease?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | [number, number, number, number]
43}
44
45/**
46 * AnimatedCounter - A component that animates numbers from 0 to a target value
47 *
48 * Features:
49 * - Smooth number animation with customizable duration and delay
50 * - Support for regular numbers and percentages
51 * - Automatic padding to prevent layout shifts during animation
52 * - Tabular numbers for consistent spacing
53 * - Viewport-triggered animation (starts when component comes into view)
54 * - Proper comma formatting for large numbers
55 *
56 * @example
57 * ```tsx
58 * // Basic usage
59 * <AnimatedCounter value={230550} />
60 *
61 * // Percentage with plus sign
62 * <AnimatedCounter
63 * value={13.4}
64 * isPercentage
65 * prefix="+"
66 * duration={3}
67 * delay={0.5}
68 * />
69 * ```
70 */
71export const AnimatedCounter: FC<AnimatedCounterProps> = ({
72 value,
73 duration = 2.5,
74 delay = 0.25,
75 isPercentage = false,
76 prefix = '',
77 className = '',
78 ease = [0.175, 0.885, 0.32, 1],
79}) => {
80 const count = useMotionValue(0)
81 const rounded = useTransform(count, (latest) =>
82 isPercentage ? Math.round(latest * 10) / 10 : Math.round(latest)
83 )
84
85 // Calculate padding based on final value
86 const getPaddedValue = (currentValue: number, targetValue: number, isPercentage: boolean) => {
87 if (isPercentage) {
88 const prefixed = prefix && currentValue > 0 ? '+' : prefix ? prefix : ''
89 const targetString = targetValue.toFixed(1)
90 const currentString = currentValue.toFixed(1)
91 const paddedCurrent = currentString.padStart(targetString.length, '0')
92 return `${prefixed}${paddedCurrent}%`
93 } else {
94 const targetString = targetValue.toLocaleString()
95 const currentString = currentValue.toLocaleString()
96 // Count digits in target (excluding commas)
97 const targetDigits = targetString.replace(/,/g, '').length
98 const currentDigits = currentString.replace(/,/g, '').length
99
100 if (currentDigits < targetDigits) {
101 const paddingNeeded = targetDigits - currentDigits
102 const currentWithoutCommas = currentValue.toString()
103 const paddedNumber = currentWithoutCommas.padStart(
104 currentWithoutCommas.length + paddingNeeded,
105 '0'
106 )
107 // Manually add commas to preserve leading zeros
108 return paddedNumber.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
109 }
110 return currentString
111 }
112 }
113
114 const displayValue = useTransform(rounded, (latest) =>
115 getPaddedValue(latest, value, isPercentage)
116 )
117
118 const ref = useRef(null)
119 const isInView = useInView(ref, { once: true })
120
121 useEffect(() => {
122 if (isInView) {
123 const controls = animate(count, value, {
124 duration,
125 delay,
126 ease,
127 // type: 'spring',
128 })
129
130 return controls.stop
131 }
132 }, [count, value, duration, delay, isInView, ease])
133
134 return (
135 <motion.span ref={ref} className={cn('tabular-nums', className)}>
136 {displayValue}
137 </motion.span>
138 )
139}