Menu

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:

terminal
npx -y vui-registry-cli-v1@latest add number-ticker

Source Code

number-ticker.tsx
'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.

NameTypeDefaultDescription
valuenumber0Target number to animate toward.
format'currency' | 'percent' | 'plain'plainNumber display format.
stiffnessnumber80Spring stiffness for the animation.
dampingnumber40Spring damping for the animation.
delaynumber0Delay in seconds before animation starts.
classNamestringundefinedAdditional class names for the ticker span.
Context Worth Keeping In Orbit

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.