74 lines
1.8 KiB
TypeScript
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>
|
|
);
|
|
}
|