Number Ticker
A spring-animated number ticker with velocity blur, currency/percent/plain formatting, step controls, and secondary stat counters. Numbers ease into place when values change or enter the viewport.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add number-tickerSource Code
'use client'
import { useEffect, useRef, useState } from 'react'
import { useInView, useMotionValue, useSpring, useVelocity, useTransform, motion, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
import { Minus, Plus, RotateCcw } from 'lucide-react'
type Format = 'currency' | 'percent' | 'plain'
export function NumberTicker({
value,
direction = 'up',
delay = 0,
className,
stiffness = 80,
damping = 40,
mass = 1,
format = 'plain',
prefix = '',
suffix = '',
}: {
value: number
direction?: 'up' | 'down'
className?: string
delay?: number
stiffness?: number
damping?: number
mass?: number
format?: Format
prefix?: string
suffix?: string
}) {
const ref = useRef<HTMLSpanElement>(null)
const motionValue = useMotionValue(direction === 'down' ? value : 0)
const springValue = useSpring(motionValue, { damping, stiffness, mass })
const isInView = useInView(ref, { once: true, margin: '0px' })
const velocity = useVelocity(springValue)
const blurValue = useTransform(velocity, [-3000, 0, 3000], [4, 0, 4])
const blurFilter = useTransform(blurValue, (v) => `blur(${v}px)`)
const formatNumber = (n: number) => {
if (format === 'currency') return Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
if (format === 'percent') return `${n}%`
return Intl.NumberFormat('en-US').format(n)
}
useEffect(() => {
if (isInView) {
const t = setTimeout(() => motionValue.set(direction === 'down' ? 0 : value), delay * 1000)
return () => clearTimeout(t)
}
}, [motionValue, isInView, delay, value, direction])
useEffect(() => {
const unsub = springValue.on('change', (latest) => {
if (ref.current) {
const formatted = format === 'currency' ? formatNumber(latest) : format === 'percent' ? `${Math.round(latest)}%` : Intl.NumberFormat('en-US').format(Math.round(latest))
ref.current.textContent = `${prefix}${formatted}${suffix}`
}
})
return unsub
}, [springValue, format, prefix, suffix])
return (
<motion.span
style={{ filter: blurFilter }}
className={cn('inline-block tabular-nums tracking-tight', className)}
ref={ref}
/>
)
}
export default function NumberTickerPreview() {
const [value, setValue] = useState(48290)
const [prevValue, setPrevValue] = useState(48290)
const [format, setFormat] = useState<Format>('currency')
const [step, setStep] = useState(1250)
const bump = (delta: number) => {
setPrevValue(value)
setValue((v) => Math.max(0, v + delta))
}
const reset = () => {
setPrevValue(value)
setValue(48290)
}
const diff = value - prevValue
const diffLabel = format === 'currency' ? `$${Math.abs(diff).toLocaleString()}` : format === 'percent' ? `${Math.abs(diff)}%` : Math.abs(diff).toLocaleString()
return (
<div className="w-full max-w-md mx-auto p-6">
<div className="rounded-xl border border-neutral-200 dark:border-white/10 bg-white dark:bg-neutral-900 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-neutral-500">Revenue</p>
<p className="text-xs text-neutral-400 mt-0.5">Live counter with spring physics</p>
</div>
<div className="flex gap-1">
{(['currency', 'percent', 'plain'] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={cn(
'px-2 py-1 rounded-md text-[10px] font-semibold uppercase transition-colors',
format === f
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-500/25'
: 'text-neutral-400 hover:text-neutral-600'
)}
>
{f}
</button>
))}
</div>
</div>
<div className="relative mb-2">
<h2 className="text-4xl font-semibold text-neutral-900 dark:text-white flex items-baseline justify-center gap-1">
{format !== 'currency' && format !== 'percent' && <span className="text-2xl text-neutral-400">#</span>}
<NumberTicker value={format === 'percent' ? Math.min(100, Math.round(value / 500)) : value} format={format} stiffness={70} damping={35} />
</h2>
<AnimatePresence>
{diff !== 0 && (
<motion.span
key={value}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={cn(
'absolute right-0 top-0 text-[11px] font-medium px-2 py-0.5 rounded-full',
diff > 0 ? 'bg-emerald-500/10 text-emerald-600' : 'bg-rose-500/10 text-rose-500'
)}
>
{diff > 0 ? '+' : '-'}{diffLabel}
</motion.span>
)}
</AnimatePresence>
</div>
<p className="text-center text-xs text-neutral-500 mb-6">Updated just now · spring easing on change</p>
<div className="flex items-center justify-center gap-2 mb-6">
<button
type="button"
onClick={() => bump(-step)}
className="p-2 rounded-lg border border-neutral-200 dark:border-white/10 text-neutral-600 hover:border-emerald-500/30 transition-colors"
>
<Minus className="w-4 h-4" />
</button>
<div className="px-3 py-1.5 rounded-lg bg-neutral-50 dark:bg-white/5 text-xs font-medium text-neutral-500 tabular-nums min-w-[72px] text-center">
±{step.toLocaleString()}
</div>
<button
type="button"
onClick={() => bump(step)}
className="p-2 rounded-lg border border-neutral-200 dark:border-white/10 text-neutral-600 hover:border-emerald-500/30 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
<button
type="button"
onClick={reset}
className="p-2 rounded-lg border border-neutral-200 dark:border-white/10 text-neutral-400 hover:text-neutral-700 transition-colors ml-1"
title="Reset"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-neutral-100 dark:border-white/5">
{[
{ label: 'Sessions', value: 12840, suffix: '' },
{ label: 'Conv.', value: 3, suffix: '%', format: 'percent' as const },
{ label: 'MRR', value: 9200, format: 'currency' as const },
].map((stat) => (
<div key={stat.label} className="text-center">
<p className="text-[9px] font-bold uppercase tracking-widest text-neutral-400 mb-1">{stat.label}</p>
<p className="text-lg font-semibold text-neutral-800 dark:text-neutral-100 tabular-nums">
<NumberTicker value={stat.value} format={stat.format ?? 'plain'} stiffness={60} damping={30} suffix={stat.suffix} />
</p>
</div>
))}
</div>
<div className="mt-4 flex gap-2">
{[500, 1250, 5000].map((s) => (
<button
key={s}
type="button"
onClick={() => setStep(s)}
className={cn(
'flex-1 py-1.5 rounded-md text-[10px] font-medium transition-colors',
step === s ? 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900' : 'bg-neutral-100 dark:bg-white/5 text-neutral-500'
)}
>
Step {s >= 1000 ? `${s / 1000}k` : s}
</button>
))}
</div>
</div>
</div>
)
}
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| value | number | 0 | Target number to animate toward. |
| format | 'currency' | 'percent' | 'plain' | plain | Number display format. |
| stiffness | number | 80 | Spring stiffness for the animation. |
| damping | number | 40 | Spring damping for the animation. |
| delay | number | 0 | Delay in seconds before animation starts. |
| className | string | undefined | Additional class names for the ticker span. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.