Free Core is open now. Pro opens next week through Founding Access.

Join Waitlist

Number Counter

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

Number Counter

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-counter

Usage

index.jsx
'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

CounterOne.jsx
'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>
 );
}
CounterTwo.jsx
'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;
CounterThree.jsx
'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?

Use Number Counter when a small number of metrics needs a clear arrival moment: proof bands, launch stats, adoption numbers, or product outcomes.

Request a Custom Text Animation

Need a custom effect? Tell us what to create.

Related Effects