57 lines
1.4 KiB
TypeScript
57 lines
1.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Animated number that counts up when it enters the viewport (once).
|
|
* - Motion `animate()` drives a raw value; we format with prefix/suffix/decimals.
|
|
* - prefers-reduced-motion: jumps straight to the final value.
|
|
* - Real number text stays in the DOM for SEO / screen readers.
|
|
*/
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { animate, useInView, useReducedMotion } from "motion/react";
|
|
import { EASE_OUT } from "./motion";
|
|
|
|
export default function CountUp({
|
|
to,
|
|
prefix = "",
|
|
suffix = "",
|
|
decimals = 0,
|
|
duration = 1.8,
|
|
}: {
|
|
to: number;
|
|
prefix?: string;
|
|
suffix?: string;
|
|
decimals?: number;
|
|
duration?: number;
|
|
}) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });
|
|
const reduce = useReducedMotion();
|
|
const [val, setVal] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!inView) return;
|
|
if (reduce) {
|
|
setVal(to);
|
|
return;
|
|
}
|
|
const controls = animate(0, to, {
|
|
duration,
|
|
ease: EASE_OUT,
|
|
onUpdate: (v) => setVal(v),
|
|
});
|
|
return () => controls.stop();
|
|
}, [inView, to, duration, reduce]);
|
|
|
|
const display = val.toLocaleString("en-US", {
|
|
minimumFractionDigits: decimals,
|
|
maximumFractionDigits: decimals,
|
|
});
|
|
|
|
return (
|
|
<span ref={ref}>
|
|
{prefix}
|
|
{display}
|
|
{suffix}
|
|
</span>
|
|
);
|
|
}
|