An animated React number with a bouncy transition. Ideal for stats, counters and pricing cards. Built with Motion and Tailwind CSS.
<BounceNumber
value={value}
className="text-6xl font-semibold"
aria-label={value.toString()}
/>npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import { useEffect, useMemo, useRef } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react";
/* ---------------------------------------------------------------------------
* Types
* --------------------------------------------------------------------------- */
type Direction = "up" | "down";
type DigitCustom = {
instanceId: number;
direction: Direction;
delay: number;
isNotDigit: boolean;
};
type ExitMeta = {
direction: Direction;
exitDelay: number;
};
/* ---------------------------------------------------------------------------
* Functions
* --------------------------------------------------------------------------- */
function toChars(value: number): string[] {
return String(value).split("");
}
function getChangedPlaces(prev: number, next: number): number[] {
const prevChars = toChars(prev);
const nextChars = toChars(next);
const maxLen = Math.max(prevChars.length, nextChars.length);
const places: number[] = [];
for (let place = 0; place < maxLen; place++) {
const prevChar = prevChars[prevChars.length - 1 - place];
const nextChar = nextChars[nextChars.length - 1 - place];
if (prevChar !== nextChar) {
places.push(place);
}
}
return places;
}
function buildStaggerMap(
changedPlaces: number[],
direction: Direction,
staggerDelaySeconds: number,
): Map<number, number> {
const ordered =
direction === "down"
? [...changedPlaces].sort((a, b) => a - b)
: [...changedPlaces].sort((a, b) => b - a);
return new Map(ordered.map((place, index) => [place, index * staggerDelaySeconds]));
}
function buildDelayByDigitKey(
prev: number,
next: number,
staggerByPlace: Map<number, number>,
): Record<string, number> {
const delayByKey: Record<string, number> = {};
for (const chars of [toChars(prev), toChars(next)]) {
for (let i = 0; i < chars.length; i++) {
const place = chars.length - 1 - i;
const staggerDelay = staggerByPlace.get(place);
if (staggerDelay !== undefined) {
delayByKey[`${chars[i]}-${i}`] = staggerDelay;
}
}
}
return delayByKey;
}
function getTransitionOptions(delay: number, shouldReduceMotion: boolean): Transition {
if (shouldReduceMotion) {
return { duration: 0, bounce: 0, delay: 0 };
}
return {
type: "spring" as const,
duration: 0.4,
bounce: 0.2,
delay,
};
}
/* ---------------------------------------------------------------------------
* Bounce Number Component
* --------------------------------------------------------------------------- */
type BounceNumberProps<T extends React.ElementType = 'div'> = {
value: number;
/** The delay between each digit in milliseconds
*
* @default 0.03
*/
staggerDelaySeconds?: number;
animateOnMount?: boolean;
as?: T;
}
const BounceNumber = <T extends React.ElementType = 'div'>({
value,
staggerDelaySeconds = 0.03,
animateOnMount = false,
as,
...props
}: BounceNumberProps<T> & React.ComponentProps<T>) => {
const prevRef = useRef(value);
const instanceCounter = useRef(0);
const instanceByPlaceRef = useRef<Map<number, number>>(new Map());
const exitMetaByInstanceRef = useRef<Map<number, ExitMeta>>(new Map());
const lastDirectionRef = useRef<Direction>("up");
const lastDelayByDigitKeyRef = useRef<Record<string, number>>({});
const shouldReduceMotion = useReducedMotion() ?? false;
const isRendered = useRef(animateOnMount);
useEffect(() => {
isRendered.current = true;
}, []);
const variants = useMemo((): Variants => {
return {
initial: ({ isNotDigit, delay, direction }: DigitCustom) => ({
y: isNotDigit ? "0%" : direction === "up" ? "100%" : "-100%",
opacity: isNotDigit ? 0 : 1,
scale: isNotDigit ? 0.5 : 1,
transition: getTransitionOptions(delay, shouldReduceMotion),
}),
animate: ({ delay, isNotDigit }: DigitCustom) => ({
y: "0%",
opacity: 1,
scale: 1,
transition: getTransitionOptions(
isNotDigit ? 0 : delay,
shouldReduceMotion,
),
}),
exit: ({ isNotDigit, instanceId }: DigitCustom) => {
const meta = exitMetaByInstanceRef.current.get(instanceId);
const direction = meta?.direction ?? "up";
const exitDelay = meta?.exitDelay ?? 0;
return {
y: isNotDigit ? "0%" : direction === "up" ? "-100%" : "100%",
opacity: isNotDigit ? 0 : 1,
scale: isNotDigit ? 0.5 : 1,
transition: getTransitionOptions(exitDelay, shouldReduceMotion),
};
},
};
}, [shouldReduceMotion]);
if (instanceByPlaceRef.current.size === 0) {
const chars = toChars(value);
for (let i = 0; i < chars.length; i++) {
const place = chars.length - 1 - i;
instanceByPlaceRef.current.set(place, ++instanceCounter.current);
}
}
if (value !== prevRef.current) {
const prev = prevRef.current;
const direction: Direction = value > prev ? "up" : "down";
const changed = getChangedPlaces(prev, value);
const staggerByPlace = buildStaggerMap(changed, direction, staggerDelaySeconds);
const delayByDigitKey = buildDelayByDigitKey(prev, value, staggerByPlace);
const prevChars = toChars(prev);
const nextInstanceByPlace = new Map(instanceByPlaceRef.current);
for (const place of changed) {
const oldInstance = instanceByPlaceRef.current.get(place);
if (oldInstance !== undefined) {
const prevIndex = prevChars.length - 1 - place;
const prevChar = prevChars[prevIndex];
exitMetaByInstanceRef.current.set(oldInstance, {
direction,
exitDelay: delayByDigitKey[`${prevChar}-${prevIndex}`] ?? 0,
});
}
nextInstanceByPlace.set(place, ++instanceCounter.current);
}
instanceByPlaceRef.current = nextInstanceByPlace;
lastDirectionRef.current = direction;
lastDelayByDigitKeyRef.current = delayByDigitKey;
prevRef.current = value;
}
const digits = toChars(value);
const Tag = (as ?? 'div') as React.ElementType;
return (
<Tag as={Tag} aria-label={value.toString()} {...props} className={`relative flex tabular-nums ${props.className ?? ""}`}>
<AnimatePresence mode="popLayout" propagate initial={isRendered.current}>
{digits.map((digit, i) => {
const place = digits.length - 1 - i;
const instanceId = instanceByPlaceRef.current.get(place);
if (instanceId === undefined) return null;
const digitKey = `${digit}-${i}`;
const isNotDigit = digit === "," || digit === "." || digit === "-";
const delay = isNotDigit
? 0
: (lastDelayByDigitKeyRef.current[digitKey] ?? 0);
const digitCustom: DigitCustom = {
instanceId,
direction: lastDirectionRef.current,
delay,
isNotDigit,
};
return (
<motion.div
aria-hidden={true}
key={place}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={getTransitionOptions(
isNotDigit ? 0 : delay,
shouldReduceMotion,
)}
className="relative inline-block overflow-hidden"
>
<AnimatePresence mode="popLayout" initial={isRendered.current}>
<motion.span
key={instanceId}
custom={digitCustom}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
className="block"
>
{digit}
</motion.span>
</AnimatePresence>
</motion.div>
);
})}
</AnimatePresence>
</Tag>
);
};
export default BounceNumber;
/*
Available on Springr
https://springr.dev/docs/components/bounce-number
*/<BounceNumber
value={value}
className="text-6xl font-semibold"
aria-label={value.toString()}
/><BounceNumber
value={value}
className="text-6xl font-semibold"
aria-label={value.toString()}
staggerDelaySeconds={0.05} // default is 0.03
/><BounceNumber
value={value}
className="text-6xl font-semibold"
aria-label={value.toString()}
animateOnMount={true} // default is false
/>It extends the props of the HTML tag it renders as. It also accepts the following custom props:
| Prop | Type | Default Value | Description |
|---|---|---|---|
| value | number | - | The value to display. |
| staggerDelaySeconds | number | 0.03 | The delay between each digit. |
| animateOnMount | boolean | false | Whether to animate the component when it is mounted. |
| as | React.ElementType | 'div' | The HTML tag to render the component as. |