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-stats2. Install dependencies
npm install gsap @gsap/reactRequired 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!