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

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.
npx hyperiux add number-counterimport NumberCounter from '@/components/effects/number-counter';
const page = () => {
return (
<>
<NumberCounter/>
</>
)
}
// Built using Hyperiux Vault: https://vault.hyperiux.com
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import NumberCounterOne from './NumberCounterOne';
import NumberCounterTwo from './NumberCounterTwo';
import NumberCounterThree from './NumberCounterThree';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const updatePreference = () => setPrefersReducedMotion(mediaQuery.matches);
updatePreference();
mediaQuery.addEventListener('change', updatePreference);
return () => mediaQuery.removeEventListener('change', updatePreference);
}, []);
return prefersReducedMotion;
}
function ReducedMotionNote() {
return (<div aria-live="polite" className="w-fit rounded-md border border-black/10 bg-[#F8F8F3] p-6 text-center shadow-sm">
<h2 className="mt-3 text-[clamp(1.5rem,4vw,3rem)] leading-none text-[#111111]">
The digits are holding still.
</h2>
<p className="mx-auto mt-4 w-[53vw] text-base leading-7 text-black/65 max-md:text-sm max-md:w-full">
Reduced motion is enabled, so the counters fade in as static numbers
instead of rolling vertically. The rolling effect depends on repeated
upward motion, and your system preference asks us to keep that still.
</p>
</div>);
}
export default function NumberCounter({ duration = 1.5, stagger = 0.1, textColor = "#021A54", }) {
const prefersReducedMotion = usePrefersReducedMotion();
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 max-md:pt-10 flex flex-col items-center gap-10">
<h1 className="text-[3vw] max-[1025px]:text-[4vw] max-md:text-[7vw] text-[#111111] text-center">
Numbers That Speak for Themselves
</h1>
<div className={`w-full flex flex-col md:flex-row items-stretch justify-center gap-8 ${prefersReducedMotion ? 'animate-[number-counter-static-fade_420ms_ease-out_both]' : ''}`}>
<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">
<NumberCounterOne key={`${replayOneKey}-${duration}-${stagger}-${textColor}`} textColor={textColor} textSize="text-[5vw] md:text-[3.2vw]" fontWeight="normal" stats={[{ value: "936", suffix: "" }]} duration={duration} stagger={stagger} reducedMotion={prefersReducedMotion}/>
</div>
<button type="button" onClick={handleReplayOne} disabled={prefersReducedMotion} className="mt-6 px-5 py-2 rounded-full bg-[#111111] text-white text-sm max-md:text-sm max-[1025px]:text-lg cursor-pointer disabled:cursor-not-allowed disabled:opacity-45">
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">
<NumberCounterTwo key={`${replayTwoKey}-${duration}-${stagger}-${textColor}`} value="594" textSize="text-[5vw] max-[1025px]:text-[7vw] max-md:text-[12vw] md:text-[3.2vw]" color={textColor} fontWeight="normal" duration={duration} stagger={stagger} reducedMotion={prefersReducedMotion}/>
</div>
<button type="button" onClick={handleReplayTwo} disabled={prefersReducedMotion} className="mt-6 px-5 py-2 max-md:text-sm max-[1025px]:text-lg rounded-full bg-[#111111] text-white text-sm cursor-pointer disabled:cursor-not-allowed disabled:opacity-45">
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">
<NumberCounterThree key={`${replayThreeKey}-${duration}-${stagger}-${textColor}`} value="246" duration={duration} stagger={stagger} fontWeight="medium" textColor={textColor} textSize="text-[5vw] max-[1025px]:text-[7vw] max-md:text-[12vw] md:text-[3.2vw]" reducedMotion={prefersReducedMotion}/>
</div>
<button type="button" onClick={handleReplayThree} disabled={prefersReducedMotion} className="mt-6 px-5 py-2 max-md:text-sm max-[1025px]:text-lg rounded-full bg-[#111111] text-white text-sm cursor-pointer disabled:cursor-not-allowed disabled:opacity-45">
Replay
</button>
</div>
</div>
{prefersReducedMotion && <ReducedMotionNote />}
<style>{`
@keyframes number-counter-static-fade {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
</div>
</>);
}
'use client';
import { useEffect, useId, 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 FONT_WEIGHTS = {
normal: "font-normal",
medium: "font-medium",
semibold: "font-semibold",
bold: "font-bold",
};
const DigitScroller = memo(({ digit, index, duration = 2, stagger = 0.1, color, trigger, reducedMotion = false }) => {
const containerRef = useRef(null);
useEffect(() => {
if (reducedMotion || window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches)
return;
const ctx = gsap.context(() => {
gsap.to(containerRef.current, {
y: `-${parseInt(digit, 10) * 10}%`,
duration,
delay: index * stagger,
ease: "power1.out",
scrollTrigger: {
trigger,
start: "top 80%",
},
});
});
return () => ctx.revert();
}, [digit, duration, index, stagger, reducedMotion, trigger]);
if (reducedMotion) {
return (<span style={{ color }} className="inline-block w-[0.64em] text-center leading-none">
{digit}
</span>);
}
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, trigger, duration, stagger, reducedMotion) => value.split("").map((char, i) => /\d/.test(char) ? (<DigitScroller key={i} digit={char} index={i} duration={duration} stagger={stagger} color={color} trigger={trigger} reducedMotion={reducedMotion}/>) : (<span key={i} style={{ color }}>{char}</span>));
const StatItem = memo(({ stat, textColor, textSize, fontWeight, trigger, duration, stagger, reducedMotion }) => {
const { prefix, value, suffix, superSuffix } = stat;
return (<div className="flex gap-[2vw] h-fit w-fit max-[1025px]:gap-0 max-md:flex-col">
<div className="flex flex-col items-center justify-start h-fit gap-[1vw] w-fit max-md:flex-row max-md:justify-between max-md:pl-[4vw] max-md:gap-[4vw]">
<h3 dir="ltr" className={`${(fontWeight && FONT_WEIGHTS[fontWeight]) || "font-normal"} leading-[1.2] flex items-center max-[1025px]:text-[7vw] max-md:text-[12vw] ${textSize}`}>
{prefix && <span style={{ color: textColor }}>{prefix}</span>}
{renderDigits(value, textColor, trigger, duration, stagger, reducedMotion)}
{suffix && <span style={{ color: textColor }}>{suffix}</span>}
{superSuffix && <sup style={{ color: textColor }}>{superSuffix}</sup>}
</h3>
</div>
</div>);
});
StatItem.displayName = "StatItem";
export default function NumberCounterOne({ stats = [], textColor = "black", textSize = "text-[5vw]", fontWeight = "normal", duration = 2, stagger = 0.1, reducedMotion = false, }) {
const uid = useId().replace(/:/g, "");
const sectionId = `stats-${uid}`;
const trigger = `#${sectionId}`;
const items = stats.slice(0, 3);
return (<section id={sectionId} className="h-fit w-fit">
<div className="p-[1vw]">
<div className="flex text-center gap-[2vw] w-fit h-fit max-[1025px]:flex-wrap max-[1025px]:gap-x-0 max-[1025px]:gap-y-12 max-md:flex-col max-md:text-left">
{items.map((stat, index) => (<StatItem key={index} stat={stat} textColor={textColor} textSize={textSize} fontWeight={fontWeight} trigger={trigger} duration={duration} stagger={stagger} reducedMotion={reducedMotion}/>))}
</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, duration = 1.5, stagger = 0.1, triggerRef, reducedMotion = false }) => {
const digitRef = useRef(null);
useEffect(() => {
if (reducedMotion || window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches)
return;
if (!digitRef.current || !triggerRef.current)
return;
const digitIndex = parseInt(digit, 10);
const ctx = gsap.context(() => {
gsap.to(digitRef.current, {
yPercent: -(digitIndex * 100),
duration,
ease: 'power2.inOut',
delay: index * stagger,
scrollTrigger: {
trigger: triggerRef.current,
start: 'top 85%',
},
});
}, triggerRef);
return () => ctx.revert();
}, [digit, index, duration, stagger, reducedMotion, triggerRef]);
if (reducedMotion) {
return (<span className="inline-flex h-[1em] w-[0.64em] items-center justify-center leading-none">
{digit}
</span>);
}
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 NumberCounterTwo = ({ value = '0', textSize = 'text-[8vw] max-[1025px]:text-[7vw] max-md:text-[12vw]', color = '#111111', fontWeight = 'normal', duration = 1.5, stagger = 0.1, reducedMotion = false, }) => {
const containerRef = useRef(null);
const cleanValue = value.replace('+', '');
return (<div ref={containerRef} className="flex items-end gap-[2vw] w-fit max-[1025px]:gap-0">
<div className={`flex items-end font-display leading-none ${textSize} ${(fontWeight && FONT_WEIGHTS[fontWeight]) || 'font-normal'}`} style={{ color }}>
{cleanValue.split('').map((digit, index) => (<DigitScroller key={`${digit}-${index}`} digit={digit} index={index} duration={duration} stagger={stagger} triggerRef={containerRef} reducedMotion={reducedMotion}/>))}
{value.includes('+') && (<span className="inline-flex h-[1em] items-center justify-center leading-none">
+
</span>)}
</div>
</div>);
};
export default NumberCounterTwo;
'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, index, duration = 1.5, stagger = 0.1, trigger, textColor, reducedMotion = false }) => {
const containerRef = useRef(null);
useEffect(() => {
if (reducedMotion || window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches)
return;
if (!containerRef.current)
return;
const ctx = gsap.context(() => {
const digitIndex = parseInt(digit, 10);
gsap.to(containerRef.current, {
y: `-${digitIndex * 10}%`,
duration,
delay: index * stagger,
ease: 'power2.out',
scrollTrigger: {
trigger: trigger || containerRef.current,
start: 'top 85%',
},
});
}, containerRef);
return () => ctx.revert();
}, [digit, index, duration, stagger, reducedMotion, trigger]);
if (reducedMotion) {
return (<span className="inline-block w-[0.6em] leading-none" style={{ color: textColor }}>
{digit}
</span>);
}
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 NumberCounterThree = ({ value = '0', duration = 1.5, stagger = 0.1, fontWeight = 600, textColor = '#FF5100', textSize = 'text-[5vw] max-[1025px]:text-[7vw] max-md:text-[12vw]', trigger, suffix = '', reducedMotion = false, }) => {
const renderDigits = (val) => {
return val.split('').map((char, i) => {
if (/\d/.test(char)) {
return (<DigitScroller key={i} digit={char} index={i} duration={duration} stagger={stagger} trigger={trigger} textColor={textColor} reducedMotion={reducedMotion}/>);
}
// non-digit chars
return (<span key={i} style={{ color: textColor }}>
{char}
</span>);
});
};
return (<div className={`flex items-end gap-[0.05em] max-[1025px]:gap-0 ${textSize}`} style={{
fontWeight,
color: textColor,
}}>
{renderDigits(value)}
{suffix && (<span style={{ color: textColor }} className="ml-1">
{suffix}
</span>)}
</div>);
};
export default NumberCounterThree;
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.
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.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 1.5 | Digit roll duration in seconds for all three counters. |
stagger | number | 0.1 | Delay between digit columns in seconds for all three counters. |
textColor | string | #021A54 | Counter text color for all three counters. |
Use Number Counter when a small number of metrics needs a clear arrival moment: proof bands, launch stats, adoption numbers, or product outcomes.
No. Metrics should resolve, settle, and stay readable. Looping proof figures makes them feel decorative instead of trustworthy.
Format values clearly with suffixes, separators, or units. Reserve enough width for the final value so the layout does not jump during animation.
Not if the final number and label render as real HTML. Avoid hiding the only meaningful value inside client-only animation.
Show the final value immediately, or use a minimal fade. Do not roll digits, scramble values, or run a long count-up sequence.
Yes. A custom version should define number formatting, motion timing, proof hierarchy, accessibility behavior, reduced-motion fallback, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.