agency-web/app/components/CountUp.tsx

74 lines
1.8 KiB
TypeScript

"use client";
/**
* CountUp — animates a number from 0 to its target when it scrolls into view.
* Uses GSAP + ScrollTrigger, formats with optional prefix/suffix/decimals and
* tabular figures. Under reduced-motion it renders the final value immediately.
* The full value is also written to the DOM on mount so it is correct even if
* JS/animation never runs (accessible + SSR-safe).
*/
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
type Props = {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
className?: string;
};
export default function CountUp({
value,
prefix = "",
suffix = "",
decimals = 0,
className = "",
}: Props) {
const ref = useRef<HTMLSpanElement>(null);
const format = (n: number) =>
`${prefix}${n.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}${suffix}`;
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.textContent = format(value);
return;
}
const obj = { n: 0 };
el.textContent = format(0);
const ctx = gsap.context(() => {
gsap.to(obj, {
n: value,
duration: 2,
ease: "power2.out",
scrollTrigger: { trigger: el, start: "top 85%", once: true },
onUpdate: () => {
el.textContent = format(obj.n);
},
});
}, el);
return () => ctx.revert();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, prefix, suffix, decimals]);
// Render full value at SSR/first paint; the effect resets to 0 then animates.
return (
<span ref={ref} className={className}>
{format(value)}
</span>
);
}