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-observerSource 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>
);
}