#buildingpublic Animated Counter
Animation insprited by this tweet by Sam Selikoff .
How it works
This animated counter component takes a number as input and animates the transition from 0 to that number. The animation is achieved using framer-motion
, a popular animation library for React. Uses a spring and absolute positioning to smoothly animate digits as they change.
Code
Here's the code for the animated counter component:
'use client';
import { cn } from '@/lib/utils';
import { MotionValue, motion, useSpring, useTransform } from 'framer-motion';
import { useEffect } from 'react';
// Constants for styling
const fontSize = 12;
const padding = 0;
const height = fontSize + padding;
// Utility function to format numbers with suffixes
const formatNumber = (
num: number
): { formattedNumber: number; suffix: string } => {
if (num >= 10 ** 9) {
const formattedNumber = num / 10 ** 9;
if (
1000 >= Math.floor(formattedNumber) &&
Math.floor(formattedNumber) > 100
) {
return { formattedNumber: formattedNumber / 1000, suffix: 'T' };
}
return { formattedNumber, suffix: 'B' };
}
if (num >= 10 ** 6) {
const formattedNumber = num / 10 ** 6;
if (
1000 >= Math.floor(formattedNumber) &&
Math.floor(formattedNumber) > 100
) {
return { formattedNumber: formattedNumber / 1000, suffix: 'B' };
}
return { formattedNumber, suffix: 'M' };
}
if (num >= 10 ** 3) {
const formattedNumber = num / 10 ** 3;
if (
1000 >= Math.floor(formattedNumber) &&
Math.floor(formattedNumber) > 100
) {
return { formattedNumber: formattedNumber / 1000, suffix: 'M' };
}
return { formattedNumber, suffix: 'K' };
}
return { formattedNumber: num, suffix: '' };
};
function Counter({
value,
withZero = false,
...props
}: { value: number; withZero?: boolean } & React.ComponentProps<'div'>) {
const { formattedNumber, suffix } = formatNumber(value);
const integerPart = Math.floor(formattedNumber);
const decimalPart = Math.floor((formattedNumber - integerPart) * 10);
return (
<div
className={cn(
'flex overflow-hidden items-end leading-none',
props.className
)}
>
{[...Array(integerPart.toString().length)]
.map((_, i) => (
<Digit
key={i}
place={10 ** i}
value={integerPart}
className={
!withZero && integerPart= 0 ? 'opacity-0' : 'opacity-100'
}
/>
))
.reverse()}
{decimalPart > 0 && (
<>
<span
style={{ height, fontWeight: 'inherit' }}
className="relative w-[0.4ch] tabular-nums leading-none text-left"
>
.
</span>
<Digit place={1} value={decimalPart} />
</>
)}
<Suffix suffix={suffix} />
</div>
);
}
function Digit({
place,
value,
className,
}: {
place: number;
value: number;
className?: string;
}) {
let valueRoundedToPlace = Math.floor(value / place);
let animatedValue = useSpring(valueRoundedToPlace);
useEffect(() => {
animatedValue.set(valueRoundedToPlace);
}, [animatedValue, valueRoundedToPlace]);
return (
<div
style={{ height, fontWeight: 'inherit' }}
className={cn(
'relative w-[0.9ch] tabular-nums transition-opacity duration-500 ease-in-out',
className
)}
>
{[...Array(10)].map((_, i) => (
<Number key={i} mv={animatedValue} number={i} />
))}
</div>
);
}
function Number({ mv, number }: { mv: MotionValue; number: number }) {
let y = useTransform(mv, (latest) => {
let placeValue = latest % 10;
let offset = (10 + number - placeValue) % 10;
let memo = offset * height;
if (offset > 5) {
memo -= 10 * height;
}
return memo;
});
return (
<motion.span
style={{ y, fontWeight: 'inherit' }}
className="absolute inset-0 flex items-center justify-center"
>
{number}
</motion.span>
);
}
const suffixes = ['', 'K', 'M', 'B'];
function Suffix({ suffix }: { suffix: string }) {
let suffixIndex = suffixes.indexOf(suffix);
let animatedValue = useSpring(suffixIndex);
useEffect(() => {
animatedValue.set(suffixIndex);
}, [animatedValue, suffixIndex]);
return (
<div
style={{ height, fontWeight: 'inherit' }}
className={`relative ${
suffix= '' ? '!w-0' : 'w-[1.1ch]'
} tabular-nums tracking-normal text-left`}
>
{suffixes.map((suffixItem, i) => (
<SuffixItem key={i} mv={animatedValue} suffix={suffixItem} index={i} />
))}
</div>
);
}
function SuffixItem({
mv,
suffix,
index,
}: {
mv: MotionValue;
suffix: string;
index: number;
}) {
let y = useTransform(mv, (latest) => {
let offset = (suffixes.length + index - latest) % suffixes.length;
let memo = offset * height;
if (offset > suffixes.length / 2) {
memo -= suffixes.length * height;
}
return memo;
});
return (
<motion.span
style={{ y, fontWeight: 'inherit' }}
className="absolute inset-0 flex items-center justify-center"
>
{suffix}
</motion.span>
);
}
export default Counter;
Explanation:
- The
Counter
component takes a number as input and formats it with the appropriate suffix (K, M, B, etc.). - The
Digit
component handles the animation of individual digits. It uses theuseSpring
hook fromframer-motion
to animate the transition between numbers. - The
Number
component animates the movement of each digit based on the current value. - The
Suffix
component handles the animation of the suffix (K, M, B) based on the current value. - The animation is achieved by updating the
MotionValue
objects and usinguseTransform
to calculate the position of each digit and suffix.
Usage
To use the animated counter component, simply import it and pass the desired number as a prop:
import Counter from '@/components/Counter';
function MyComponent() {
return <Counter value={123456} />;
}
You can customize the appearance of the counter by passing additional props, such as withZero
to show leading zeros.