Number Counter
Give important numbers a visible moment of arrival, so metrics feel noticed without becoming noisy or hard to trust.

Overview
Number Counter gives numbers a moment of arrival. The metric does not just appear; it resolves into proof.
Use Number Counter when a number deserves attention: revenue lift, active users, conversion improvement, uptime, launch progress, speed gain, or product adoption. The page job is proof: the animation should make the figure feel noticed, not make it harder to read.
The production risk is credibility. A counter that spins too long, loops forever, or animates every number on the page starts to feel like theatre around evidence. The final value must settle quickly, remain readable as real text, and avoid pretending that decorative motion is data.
Install
npx hyperiux add number-counterUsage
'use client'
import React, { useCallback, useState } from'react'
import CounterOne from'./CounterOne'
import CounterTwo from'./CounterTwo'
import CounterThree from'./CounterThree'
export default function Counter() {
const [replayOneKey, setReplayOneKey] = useState(0)
const [replayTwoKey, setReplayTwoKey] = useState(0)
const [replayThreeKey, setReplayThreeKey] = useState(0)
const handleReplayOne = useCallback(() => setReplayOneKey((k) => k + 1), [])
const handleReplayTwo = useCallback(() => setReplayTwoKey((k) => k + 1), [])
const handleReplayThree = useCallback(() => setReplayThreeKey((k) => k + 1), [])
return (
<>
<div className="min-h-screen w-screen bg-white flex items-center justify-center p-10">
<div className="w-full max-w-6xl flex flex-col items-center gap-10">
<h2 className="text-[3vw] max-md:text-[4vw] max-sm:text-[7vw] text-[#111111] text-center">
Numbers That Speak for Themselves
</h2>
<div className="w-full flex flex-col md:flex-row items-stretch justify-center gap-8">
<div className="flex-1 min-w-65 rounded-md border border-black/10 bg-white shadow-sm p-8 flex flex-col items-center">
<div className="flex-1 w-full flex items-center justify-center">
<CounterOne
key={replayOneKey}
textColor="#021A54"
textSize="text-[5vw] md:text-[3.2vw]"
fontWeight="normal"
stats={[{ value:"936", suffix:"" }]}
/>
</div>
<button
type="button"
onClick={handleReplayOne}
className="mt-6 px-5 py-2 rounded-full bg-[#111111] text-white text-sm max-sm:text-sm max-md:text-lg cursor-pointer"
>
Replay
</button>
</div>
<div className="flex-1 min-w-[260px] rounded-md border border-black/10 bg-white shadow-sm p-8 flex flex-col items-center">
<div className="flex-1 w-full flex items-center justify-center">
<CounterTwo
key={replayTwoKey}
value="594"
textSize="text-[5vw] max-md:text-[7vw] max-sm:text-[12vw] md:text-[3.2vw]"
color="#021A54"
fontWeight="normal"
/>
</div>
<button
type="button"
onClick={handleReplayTwo}
className="mt-6 px-5 py-2 max-sm:text-sm max-md:text-lg rounded-full bg-[#111111] text-white text-sm cursor-pointer"
>
Replay
</button>
</div>
<div className="flex-1 min-w-[260px] rounded-md border border-black/10 bg-white shadow-sm p-8 flex flex-col items-center">
<div className="flex-1 w-full flex items-center justify-center">
<CounterThree
key={replayThreeKey}
value="246"
duration={2}
fontWeight="medium"
textColor="#021A54"
textSize="text-[5vw] max-md:text-[7vw] max-sm:text-[12vw] md:text-[3.2vw]"
/>
</div>
<button
type="button"
onClick={handleReplayThree}
className="mt-6 px-5 py-2 max-sm:text-sm max-md:text-lg rounded-full bg-[#111111] text-white text-sm cursor-pointer"
>
Replay
</button>
</div>
</div>
</div>
</div>
</>
)
}
Component Code
'use client';
import { useEffect, useRef, memo } from"react";
import gsap from"gsap";
import { ScrollTrigger } from"gsap/dist/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const DIGITS = [...Array(10).keys()]; // [0..9]
const SCROLL_TRIGGER_CONFIG = {
trigger:"#stats",
start:"top 80%",
};
const FONT_WEIGHTS = {
normal:"font-normal",
medium:"font-medium",
semibold:"font-semibold",
bold:"font-bold",
};
const DigitScroller = memo(({ digit, duration = 2, color }) => {
const containerRef = useRef(null);
useEffect(() => {
const ctx = gsap.context(() => {
gsap.to(containerRef.current, {
y: `-${parseInt(digit, 10) * 10}%`,
duration,
ease:"power1.out",
scrollTrigger: SCROLL_TRIGGER_CONFIG,
});
});
return () => ctx.revert();
}, [digit, duration]);
return (
<div
style={{ color }}
className="overflow-hidden h-[1em] leading-none inline-block relative w-[0.64em]"
>
<div ref={containerRef} className="flex flex-col">
{DIGITS.map((d) => (
<span key={d} className="flex h-[1em] items-center justify-center leading-none">
{d}
</span>
))}
</div>
</div>
);
});
DigitScroller.displayName ="DigitScroller";
const renderDigits = (value, color) =>
value.split("").map((char, i) =>
/\d/.test(char) ? (
<DigitScroller key={i} digit={char} color={color} />
) : (
<span key={i} style={{ color }}>{char}</span>
)
);
const StatItem = memo(({ stat, textColor, textSize, fontWeight }) => {
const { prefix, value, suffix, superSuffix } = stat;
return (
<div className="flex gap-[2vw] h-fit w-fit max-md:gap-0 max-sm:flex-col">
<div className="flex flex-col items-center justify-start h-fit gap-[1vw] w-fit max-sm:flex-row max-sm:justify-between max-sm:pl-[4vw] max-sm:gap-[4vw]">
<h3
dir="ltr"
className={`${FONT_WEIGHTS[fontWeight] ||"font-normal"} leading-[1.2] flex items-center max-md:text-[7vw] max-sm:text-[12vw] ${textSize}`}
>
{prefix && <span style={{ color: textColor }}>{prefix}</span>}
{renderDigits(value, textColor)}
{suffix && <span style={{ color: textColor }}>{suffix}</span>}
{superSuffix && <sup style={{ color: textColor }}>{superSuffix}</sup>}
</h3>
</div>
</div>
);
});
StatItem.displayName ="StatItem";
/**
* @param {Object[]} stats
* @param {string} textColor
* @param {string} textSize * @param {string} fontWeight */
export default function CounterOne({
stats = [],
textColor ="black",
textSize ="text-[5vw]",
fontWeight ="normal",
}) {
const items = stats.slice(0, 3);
return (
<section id="stats" className="h-fit w-fit">
<div className="p-[1vw]">
<div className="flex text-center gap-[2vw] w-fit h-fit max-md:flex-wrap max-md:gap-x-0 max-md:gap-y-12 max-sm:flex-col max-sm:text-left">
{items.map((stat, index) => (
<StatItem
key={index}
stat={stat}
textColor={textColor}
textSize={textSize}
fontWeight={fontWeight}
/>
))}
</div>
</div>
</section>
);
}'use client';
import { memo, useEffect, useRef } from'react';
import gsap from'gsap';
import { ScrollTrigger } from'gsap/dist/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const DIGITS = [...Array(10).keys()].concat(0);
const FONT_WEIGHTS = {
normal:'font-normal',
medium:'font-medium',
semibold:'font-semibold',
bold:'font-bold',
};
const DigitScroller = memo(({ digit, index, triggerRef }) => {
const digitRef = useRef(null);
useEffect(() => {
if (!digitRef.current || !triggerRef.current) return;
const digitIndex = parseInt(digit, 10);
const ctx = gsap.context(() => {
gsap.to(digitRef.current, {
yPercent: -(digitIndex * 100),
duration: 1.5,
ease:'power2.inOut',
delay: index * 0.1,
scrollTrigger: {
trigger: triggerRef.current,
start:'top 85%',
},
});
}, triggerRef);
return () => ctx.revert();
}, [digit, index, triggerRef]);
return (
<div className="relative inline-flex h-[1em] w-[0.64em] overflow-hidden align-baseline leading-none">
<div ref={digitRef} className="flex flex-col will-change-transform">
{DIGITS.map((num, digitIndex) => (
<span
key={`${num}-${digitIndex}`}
className="flex h-[1em] items-center justify-center leading-none"
>
{num}
</span>
))}
</div>
</div>
);
});
DigitScroller.displayName ='DigitScroller';
const CounterTwo = ({
value ='0',
textSize ='text-[8vw] max-md:text-[7vw] max-sm:text-[12vw]',
color ='#111111',
fontWeight ='normal', }) => {
const containerRef = useRef(null);
const cleanValue = value.replace('+','');
return (
<div ref={containerRef} className="flex items-end gap-[2vw] w-fit max-md:gap-0">
<div
className={`flex items-end font-display leading-none ${textSize} ${FONT_WEIGHTS[fontWeight] ||'font-normal'}`}
style={{ color }}
>
{cleanValue.split('').map((digit, index) => (
<DigitScroller
key={`${digit}-${index}`}
digit={digit}
index={index}
triggerRef={containerRef}
/>
))}
{value.includes('+') && (
<span className="inline-flex h-[1em] items-center justify-center leading-none">
+
</span>
)}
</div>
</div>
);
};
export default CounterTwo;
'use client';
import React, { useEffect, useRef, memo } from'react';
import gsap from'gsap';
import { ScrollTrigger } from'gsap/dist/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const DigitScroller = memo(({ digit, duration = 1.5, trigger, textColor }) => {
const containerRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const digitIndex = parseInt(digit, 10);
gsap.to(containerRef.current, {
y: `-${digitIndex * 10}%`,
duration,
ease:'power2.out',
scrollTrigger: {
trigger: trigger || containerRef.current,
start:'top 85%',
},
});
}, containerRef);
return () => ctx.revert();
}, [digit, duration, trigger]);
return (
<div className="overflow-hidden inline-block relative h-[1em] w-[0.6em]">
<div ref={containerRef} className="flex flex-col">
{Array.from({ length: 10 }, (_, i) => (
<span
key={i}
className="leading-none"
style={{ color: textColor }}
>
{i}
</span>
))}
</div>
</div>
);
});
DigitScroller.displayName ='DigitScroller';
const CounterThree = ({
value ='0',
duration = 1.5,
fontWeight = 600,
textColor ='#FF5100',
textSize ='text-[5vw] max-md:text-[7vw] max-sm:text-[12vw]',
trigger,
suffix ='',
}) => {
const renderDigits = (val) => {
return val.split('').map((char, i) => {
if (/\d/.test(char)) {
return (
<DigitScroller
key={i}
digit={char}
duration={duration}
trigger={trigger}
textColor={textColor}
/>
);
}
// non-digit chars
return (
<span key={i} style={{ color: textColor }}>
{char}
</span>
);
});
};
return (
<div
className={`flex items-end gap-[0.05em] max-md:gap-0 ${textSize}`}
style={{
fontWeight,
color: textColor,
}}
>
{renderDigits(value)}
{suffix && (
<span style={{ color: textColor }} className="ml-1">
{suffix}
</span>
)}
</div>
);
};
export default CounterThree;
Example Production Use Case
A SaaS landing page can use Number Counter in a proof band below the hero: onboarding lift, deployment time saved, and uptime. The outcome is credibility: the numbers get a controlled beat of attention before the visitor reaches the CTA.
Best Used For
- Metric bands where a small set of proof points needs emphasis.
- Launch pages where numbers support the claim without becoming the whole story.
- SaaS, AI, and developer-tool pages where quantitative proof deserves a visible arrival.
Not For
Not for financial dashboards, compliance tables, legal figures, or analytics screens where exact reading speed matters more than animation.
Not for pages where every number spins. That is not proof; that is a slot machine with a marketing degree.
Performance Budget
Limit animated counters to the numbers that matter, avoid continuous loops, reserve layout width for the final value, and stop animation once the metric has resolved.
Accessibility and Mobile
Expose the final number and label as readable text. Do not make screen readers listen to every intermediate digit. On mobile and reduced motion, show the final value immediately or use a very short opacity change.
Common Mistakes
- Animating every statistic on the page.
- Letting the number shift layout as digits change.
- Making the animation more memorable than the metric.
Frequently Asked Questions
When should I use Number Counter?
Should Number Counter loop?
How should Number Counter handle large values?
Does Number Counter affect SEO?
What should reduced motion do for Number Counter?
Can Hyperiux adapt Number Counter for a real brand system?
Request a Custom Text Animation
Need a custom effect? Tell us what to create.



