Skip to main content

Animation

Customer stats

Avatar pill with animated counter

Client · GSAP

Live preview

Interact with the component below — same behavior as on the About page.

Loading preview…

Summary

A compact, rounded stats badge built to match a Figma layout exactly: three overlapping customer avatars, a bold numeric count, and a supporting label. The count animates from 0 to its target value using GSAP + ScrollTrigger, respecting prefers-reduced-motion.

  • Pixel-perfect spacing, radius, and typography from Figma
  • GSAP tween updates formatted integer text
  • ScrollTrigger runs once when entering view
  • Respects prefers-reduced-motion

Add to your project

Run the CLI to copy files into your project. Pick your language and styling below — the command updates automatically.

1. Add via CLI (recommended)

npx @shashank-portfolio/cli add customer-stats

2. Install dependencies

npm install gsap @gsap/react

Required packages

  • gsap ^3.15.0
  • @gsap/react ^2.1.2 · useGSAP hook

3. Manual installation

Copy the component file and avatar images into your project. Adjust import paths and image src values to match your setup.

customer-stats.tsx

"use client";

import gsap from "gsap";
import Image from "next/image";
import { useEffect, useId, useMemo, useRef, type ReactNode } from "react";

export type CustomerStatsProps = {
  className?: string;
  value?: number;
  label?: string;
};

function formatNumber(n: number): string {
  return new Intl.NumberFormat("en-US").format(Math.round(n));
}

// Fixed pixel height per digit row — matches text-[24px] with line-height 1.
const DIGIT_H = 28;
const SPINS = 2;
const TOTAL_ROWS = SPINS * 10 + 10; // 30 rows per column

export function CustomerStats({
  className,
  value = 38182,
  label = "Happy Customers",
}: CustomerStatsProps): ReactNode {
  const id = useId();
  const formatted = useMemo(() => formatNumber(value), [value]);

  return (
    <div
      className={[
        "flex w-fit items-center gap-3 rounded-[100px] bg-[#353539] py-1 pl-2 pr-3",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      aria-label={`${formatted} ${label}`}
    >
      <div className="flex shrink-0 items-center">
        <Avatar src="/components/customer-stats/avatar-1.png" alt="" overlap />
        <Avatar src="/components/customer-stats/avatar-2.png" alt="" overlap />
        <Avatar src="/components/customer-stats/avatar-3.png" alt="" />
      </div>

      <div
        className="flex shrink-0 items-center gap-2 whitespace-nowrap"
        aria-labelledby={`${id}-number ${id}-label`}
      >
        <RollingNumber id={`${id}-number`} value={formatted} />
        <span
          id={`${id}-label`}
          className="text-[16px] font-normal text-[rgba(255,255,255,0.8)]"
        >
          {label}
        </span>
      </div>
    </div>
  );
}

function RollingNumber({
  id,
  value,
}: {
  id: string;
  value: string;
}): ReactNode {
  const wrapRef = useRef<HTMLSpanElement | null>(null);
  const colRefs = useRef<Array<HTMLSpanElement | null>>([]);

  const parts = value.split("");
  const digitParts = useMemo(() => parts.filter((ch) => ch !== ","), [value]);

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;

    const reducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    const cols = colRefs.current.slice(0, digitParts.length);

    // Set all columns to start position immediately (even with reduced motion)
    cols.forEach((col, i) => {
      if (!col) return;
      const targetDigit = Number(digitParts[i]);
      const startY = -(Math.floor(Math.random() * 10) * DIGIT_H);
      gsap.set(col, { y: startY });
      if (reducedMotion) {
        // Jump straight to final value
        gsap.set(col, { y: -(SPINS * 10 + targetDigit) * DIGIT_H });
      }
    });

    if (reducedMotion) return;

    // Build paused timeline
    const tl = gsap.timeline({ paused: true });

    cols.forEach((col, i) => {
      if (!col) return;
      const targetDigit = Number(digitParts[i]);
      const endY = -(SPINS * 10 + targetDigit) * DIGIT_H;
      // right-to-left stagger: last digit animates first
      tl.to(
        col,
        { y: endY, duration: 1.5, ease: "power3.out" },
        (cols.length - 1 - i) * 0.08
      );
    });

    let played = false;

    const play = () => {
      if (played) return;
      played = true;
      tl.play();
    };

    // IntersectionObserver works inside any scroll container
    const io = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) play();
      },
      { threshold: 0.1 }
    );
    io.observe(wrap);

    // Already visible on mount? play right away.
    const r = wrap.getBoundingClientRect();
    if (r.top < window.innerHeight && r.bottom > 0) play();

    return () => {
      io.disconnect();
      tl.kill();
    };
  }, [value]);

  let digitIdx = 0;

  return (
    <span
      ref={wrapRef}
      id={id}
      className="inline-flex items-center text-[24px] font-bold tabular-nums"
    >
      <span className="sr-only">{value}</span>
      <span aria-hidden="true" className="inline-flex items-center">
        {parts.map((ch, i) => {
          if (ch === ",") {
            return (
              <span
                key={`sep-${i}`}
                style={{
                  display: "inline-block",
                  color: "white",
                  lineHeight: `${DIGIT_H}px`,
                }}
              >
                ,
              </span>
            );
          }

          const ci = digitIdx;
          digitIdx += 1;

          return (
            <span
              key={`d-${i}`}
              style={{
                display: "inline-block",
                position: "relative",
                height: DIGIT_H,
                width: "0.62em",
                overflow: "hidden",
              }}
            >
              <span
                ref={(node) => {
                  colRefs.current[ci] = node;
                }}
                style={{ display: "block" }}
              >
                {Array.from({ length: TOTAL_ROWS }, (_, idx) => idx % 10).map(
                  (n, idx) => (
                    <span
                      key={idx}
                      style={{
                        display: "block",
                        height: DIGIT_H,
                        lineHeight: `${DIGIT_H}px`,
                        color: "white",
                        textAlign: "center",
                      }}
                    >
                      {n}
                    </span>
                  )
                )}
              </span>
            </span>
          );
        })}
      </span>
    </span>
  );
}

function Avatar({
  src,
  alt,
  overlap,
}: {
  src: string;
  alt: string;
  overlap?: boolean;
}): ReactNode {
  return (
    <span
      className={[
        "relative h-8 w-8 overflow-hidden rounded-full border-[1.036px] border-[#353539] bg-[#e4e4e4]",
        overlap ? "-mr-3" : "",
      ]
        .filter(Boolean)
        .join(" ")}
      aria-hidden="true"
    >
      <Image
        src={src}
        alt={alt}
        fill
        sizes="32px"
        className="object-cover"
        priority={false}
      />
    </span>
  );
}

Uses next/image — copy public/components/customer-stats/ from this repo for avatar PNGs.

4. Use in your app

"use client";

import { CustomerStats } from "@/components/components/customer-stats";

export function HeroBadges() {
  return <CustomerStats value={38182} label="Happy Customers" />;
}

Let’s connect

I’m always open to discussing new projects, creative ideas, or opportunities to be part of your visions. Just reach out!

2026 © Built with Next.js

By Love ❤️