docsAnimated Number

Animated Number

An animated number component that smoothly transitions between values, featuring both slot-machine and score-style animations.

Slot Machine Style

Loading Preview...

Score Style

Loading Preview...

Install using CLI

npx shadcn@latest add "https://www.vengenceui.com/r/animated-number.json"

Install Manually

1

Install dependencies

npm install framer-motion lucide-react clsx tailwind-merge
2

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3

Copy the source code

Copy the code below and paste it into components/ui/animated-number.tsx

"use client";
import React, { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
function AnimatedNumber({ value, className }: { value: number, className?: string }) {
return (
<div className={cn("flex items-center", className)}>
<div className="flex relative items-center">
{value.toString().split("").map((digit, index) => (
<SingleNumberHolder key={index} value={digit} index={index} />
))}
</div>
</div>
)
}
function SingleNumberHolder({ value, index }: { value: string, index: number }) {
const [height, setHeight] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
let notANumber = false
useEffect(() => {
if (containerRef.current) {
setHeight(getComputedStyle(containerRef.current).height)
}
}, [])
if (index === 0) {
notANumber = isNaN(Number.parseInt(value))
}
const vars = {
init: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
return (
<div
className="relative"
style={{ height: height || "auto", overflowY: "hidden", overflowX: "clip" }}
ref={containerRef}
>
{notANumber && (
<motion.span
initial="init"
animate="animate"
exit="exit"
variants={vars}
key={value}
layout="size"
>
{value}
</motion.span>
)}
{!notANumber && <RenderStrip value={value} eleHeight={height} />}
</div>
)
}
const zeroToNine = Array.from({ length: 10 }, (_, k) => k)
function RenderStrip({ eleHeight, value }: { eleHeight: string | null, value: string }) {
const heightInNumber = Number.parseInt(eleHeight?.replace("px", "") || "48")
const negative = heightInNumber * -1
const pos = heightInNumber
const prev = useRef(value)
// Convert string values to numbers for comparison
const currentVal = parseInt(value)
const prevVal = parseInt(prev.current)
// Calculate direction based on value change
const diff = prevVal - currentVal
const dir = currentVal > prevVal ? pos * diff * -1 : negative * diff
// Update ref after calculation
useEffect(() => {
prev.current = value
}, [value])
return (
<AnimatePresence mode='wait'>
<motion.div
key={value}
initial={{ y: dir }}
animate={{ y: 0 }}
exit={{ y: 0, transition: { duration: 0.1 } }}
transition={{ duration: 0.5, ease: "easeOut" }}
className='flex relative flex-col'
>
{/* Numbers smaller than current */}
<motion.span
layout
key={`negative-${value}`}
className={cn('flex flex-col items-center absolute bottom-full left-0')}
>
{zeroToNine.filter(val => val < currentVal).map((val, idx) => (
<span key={`${val}_${idx}`}>{val}</span>
))}
</motion.span>
{/* Current Number */}
<span key={`current-${value}`}>{value}</span>
{/* Numbers larger than current */}
<motion.span
layout
key={`positive-${value}`}
className={cn('flex flex-col items-center absolute top-full left-0')}
>
{zeroToNine.filter(val => val > currentVal).map((val, idx) => (
<span key={`${val}_${idx}`}>{val}</span>
))}
</motion.span>
</motion.div>
</AnimatePresence>
)
}
// Score-style animated number with color feedback
function AnimatedScore({ value, duration = 0.2, className }: { value: number, duration?: number, className?: string }) {
const prevValueRef = useRef(value)
useEffect(() => {
prevValueRef.current = value
}, [value])
const colors = {
negative: "#37ff1a",
positive: "#ff1a4b",
neutral: "#fff"
}
const transforVal = 80
const forwards = {
init: { y: transforVal * -1, opacity: 0, scale: 0.5, color: colors.negative },
animate: {
y: 0,
opacity: 1,
scale: [1.7, 1],
color: [colors.negative, colors.negative, colors.neutral],
transition: { duration: 0.4, times: [0, 0.7, 1], color: { times: [0, 0.75, 0.9] } },
},
exit: {
y: transforVal,
opacity: 0,
scale: 0.5,
color: colors.positive
},
}
const backwards = {
init: { y: transforVal, opacity: 0, scale: 0.5, color: colors.positive },
animate: {
y: 0,
opacity: 1,
scale: [1.7, 1],
color: [colors.positive, colors.positive, colors.neutral],
transition: { duration: 0.4, times: [0, 0.7, 1], color: { times: [0, 0.75, 0.9] } },
},
exit: {
y: transforVal * -1,
opacity: 0,
scale: 0.5,
color: colors.negative
}
}
const variants = value >= prevValueRef.current ? forwards : backwards
const direction = value >= prevValueRef.current ? "forwards" : "backwards"
return (
<div className={cn("relative flex justify-center items-center py-1 px-2 w-full rounded-md", className)}>
<motion.div layout="size" className='w-fit flex justify-center items-center'>
{value.toString().split("").map((number, index) => (
<ScoreContainer
direction={direction}
duration={duration}
variants={variants}
number={number}
key={index}
/>
))}
</motion.div>
</div>
)
}
function ScoreContainer({ number, variants, duration = 0.7, direction }: {
number: string,
variants: any,
duration?: number,
direction: string
}) {
const cached = React.useMemo(() => (
<div className="relative">
<AnimatePresence mode="popLayout">
<motion.div
animate="animate"
className="flex justify-center items-center"
initial="init"
exit="exit"
variants={variants}
key={number.toString()}
layout="size"
transition={{ duration, ease: "backInOut" }}
>
{number}
</motion.div>
</AnimatePresence>
</div>
), [number, direction, variants, duration])
return <React.Fragment>{cached}</React.Fragment>
}
export { AnimatedNumber, AnimatedScore }

Usage

1
2"use client";
3import { useState } from "react";
4import { Minus, Plus } from "lucide-react";
5import { motion } from "framer-motion";
6import { AnimatedNumber, AnimatedScore } from "@/components/ui/animated-number"
7
8export function UsageExample() {
9return (
10 <div className="flex flex-col gap-4">
11 {/* Slot machine style */}
12 <AnimatedScoreDemo />
13
14 {/* Score style with color feedback */}
15 <AnimatedNumberDemo />
16 </div>
17
18);
19}
20
21function AnimatedNumberDemo() {
22const [num, setNum] = useState(100);
23
24 function minus() {
25 setNum(prev => prev - 10);
26 }
27 function add() {
28 setNum(prev => prev + 10);
29 }
30
31 return (
32 <div className="flex bg-zinc-950 py-2.5 px-5 rounded-full items-center justify-center border border-zinc-800">
33 <motion.button
34 whileTap={{ scale: 0.9 }}
35 onClick={minus}
36 style={{ cursor: "pointer", backgroundColor: "#27272a " }}
37 className="bg-zinc-800 text-zinc-400 hover:text-white h-11 w-11 flex justify-center items-center rounded-full transition-colors"
38 >
39 <Minus className="h-6 w-6" />
40 </motion.button>
41 <div className="flex relative px-6 text-5xl items-center font-mono text-white">
42 <AnimatedNumber value={num} />
43 </div>
44 <motion.button
45 whileTap={{ scale: 0.9 }}
46 onClick={add}
47 style={{ cursor: "pointer", backgroundColor: "#27272a " }}
48 className="bg-zinc-800 text-zinc-400 hover:text-white h-11 w-11 flex justify-center items-center rounded-full transition-colors"
49 >
50 <Plus className="h-6 w-6" />
51 </motion.button>
52 </div>
53 );
54
55}
56
57function AnimatedScoreDemo() {
58const [score, setScore] = useState(100);
59
60 function decrease() {
61 setScore(prev => prev - 10);
62 }
63 function increase() {
64 setScore(prev => prev + 10);
65 }
66
67 return (
68 <div className="flex bg-zinc-950 py-2.5 px-5 rounded-full items-center justify-center border border-zinc-800">
69 <motion.button
70 whileTap={{ scale: 0.9 }}
71 style={{ cursor: "pointer", backgroundColor: "#27272a " }}
72 onClick={decrease}
73 className="bg-zinc-800 text-zinc-400 hover:text-white h-11 w-11 flex justify-center items-center rounded-full transition-colors"
74 >
75 <Minus className="h-6 w-6" />
76 </motion.button>
77 <div className="flex relative px-6 text-5xl items-center font-mono">
78 <AnimatedScore value={score} />
79 </div>
80 <motion.button
81 whileTap={{ scale: 0.9 }}
82 onClick={increase}
83 style={{ cursor: "pointer", backgroundColor: "#27272a " }}
84 className="bg-zinc-800 text-zinc-400 hover:text-white h-11 w-11 flex justify-center items-center rounded-full transition-colors"
85 >
86 <Plus className="h-6 w-6" />
87 </motion.button>
88 </div>
89 );
90
91}
92
93

Props

AnimatedNumber

Prop NameTypeDefaultDescription
valuenumber-The numeric value to animate to.
classNamestring-Additional CSS classes for styling.

AnimatedScore

Prop NameTypeDefaultDescription
valuenumber-The numeric value to animate to.
durationnumber0.2Duration of the animation in seconds.
classNamestring-Additional CSS classes for styling.