Component

Smooth Counter

A scroll-triggered animated number counter that counts up from 0 to a target value using easeOutQuart easing. Supports automatic number formatting (12k, 1.5m), prefix/suffix, staggered entrance animations, and a glowing dot accent.

Features

  • Dynamic Interaction: The component leverages Framer Motion to provide smooth animations and transitions, making the card stack visually appealing and interactive.
  • Scroll-Triggered Animation: The counter detects when it enters the viewport via react-intersection-observer, so the count-up only fires when users actually see it.
  • Smooth Easing Engine: Powered by requestAnimationFrame with easeOutQuart easing — the counter ramps quickly then gracefully settles, far more natural than linear ticking.
  • Smart Number Formatting: Large values auto-abbreviate for readability — 12,000 becomes "12k" and 1,500,000 becomes "1.5m" — so big numbers stay scannable.
  • Fully Configurable: Customize start, target, duration, prefix, suffix, and label per instance, plus a delay prop to stagger multiple counters into a polished cascade.
  • Entrance Motion: Each card fades and slides into place with Motion (previously Framer Motion), adding polish that mirrors how premium SaaS landing pages reveal their stats.
By the numbers

Trusted by thousands.

0+

Components

0k+

Developers

0%

Satisfaction

v0

Releases

Installation

01.Install Dependencies

npm install motion react-intersection-observer
Source Code
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { useInView } from 'react-intersection-observer';

const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4);

const formatValue = (value, target) => {
  if (target >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
  if (target >= 1_000)     return `${Math.round(value / 1_000)}k`;
  return value.toLocaleString();
};

export default function SmoothCounter({
  from = 0,
  to,
  duration = 2000,
  prefix = '',
  suffix = '',
  label,
  delay = 0,
}) {
  const [displayValue, setDisplayValue] = useState(from);
  const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.3 });

  useEffect(() => {
    if (!inView) return;
    let frameId;
    const startTime = performance.now();
    const tick = (now) => {
      const t = Math.min((now - startTime) / duration, 1);
      setDisplayValue(Math.round(from + (to - from) * easeOutQuart(t)));
      if (t < 1) frameId = requestAnimationFrame(tick);
    };
    frameId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frameId);
  }, [inView, from, to, duration]);

  return (
    <motion.div
      ref={ref}
      initial={false}
      animate={{ opacity: inView ? 1 : 0, y: inView ? 0 : 32 }}
      transition={{ duration: 0.6, ease: [0.33, 1, 0.68, 1], delay }}
      className="group relative bg-zinc-950 border border-white/[0.06] rounded-2xl p-5 sm:p-6 md:p-8 flex flex-col
                 hover:border-white/[0.12] hover:bg-[#111111] transition-all duration-500"
    >
      <div className="w-1.5 h-1.5 rounded-full bg-yellow-400 mb-5 sm:mb-6 md:mb-8
                      shadow-[0_0_10px_3px_rgba(234,179,8,0.5)]" />
      <span className="text-4xl sm:text-5xl md:text-6xl font-black bg-gradient-to-b from-white via-white to-zinc-400
                       bg-clip-text text-transparent leading-none">
        {prefix}{formatValue(displayValue, to)}{suffix}
      </span>
      <p className="text-[10px] sm:text-xs text-zinc-500 mt-3 md:mt-4 tracking-widest uppercase font-medium">
        {label}
      </p>
    </motion.div>
  );
}
sparkui