Created
October 5, 2025 04:33
-
-
Save josephdburdick/a71f81349b1577e7c7e0c1350c927c4a to your computer and use it in GitHub Desktop.
Animate number up or down with motion/react
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 'use client' | |
| import { useEffect, useRef, useState } from 'react' | |
| import { cn } from '@/utils/cn' | |
| interface AnimateNumberProps { | |
| from?: number | |
| to: number | |
| duration?: number | |
| delay?: number | |
| className?: string | |
| formatter?: (value: number) => string | |
| ease?: string | |
| onComplete?: () => void | |
| } | |
| export function AnimateNumber({ | |
| from = 0, | |
| to, | |
| duration = 1.5, | |
| delay = 0, | |
| className, | |
| formatter = (value) => Math.round(value).toString(), | |
| onComplete, | |
| }: AnimateNumberProps) { | |
| const [displayValue, setDisplayValue] = useState(from) | |
| const animationRef = useRef<number | null>(null) | |
| const lastTargetRef = useRef<number | null>(null) // Initialize with null instead | |
| const displayValueRef = useRef(displayValue) | |
| // Update the ref whenever displayValue changes | |
| displayValueRef.current = displayValue | |
| useEffect(() => { | |
| // Only start animation if target value actually changed (or this is the first render) | |
| if (lastTargetRef.current !== null && lastTargetRef.current === to) { | |
| return | |
| } | |
| // Cancel any existing animation | |
| if (animationRef.current !== null) { | |
| cancelAnimationFrame(animationRef.current) | |
| } | |
| const timeout = setTimeout(() => { | |
| // Set the target ref here, after we're committed to starting the animation | |
| lastTargetRef.current = to | |
| const startTime = Date.now() | |
| const startValue = displayValueRef.current // Use ref instead of closure | |
| const difference = to - startValue | |
| const animate = () => { | |
| const elapsed = Date.now() - startTime | |
| const progress = Math.min(elapsed / (duration * 1000), 1) | |
| // Simple easeOut function | |
| const easeProgress = 1 - Math.pow(1 - progress, 3) | |
| const currentValue = startValue + difference * easeProgress | |
| setDisplayValue(currentValue) | |
| if (progress < 1) { | |
| animationRef.current = requestAnimationFrame(animate) | |
| } else { | |
| setDisplayValue(to) | |
| onComplete?.() | |
| animationRef.current = null | |
| } | |
| } | |
| animationRef.current = requestAnimationFrame(animate) | |
| }, delay * 1000) | |
| return () => { | |
| clearTimeout(timeout) | |
| if (animationRef.current !== null) { | |
| cancelAnimationFrame(animationRef.current) | |
| } | |
| } | |
| }, [to, duration, delay, onComplete]) | |
| return ( | |
| <span className={cn('tabular-nums tracking-tighter', className)}> | |
| {formatter(displayValue)} | |
| </span> | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment