Files

85 lines
2.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
/**
* Number that ticks up from 0 → `value` when scrolled into view.
* Uses requestAnimationFrame only (no animation library).
*
* CUSTOMIZATION HOOK — CONTENT: value / prefix / suffix.
*/
export default function Counter({
value,
duration = 1600,
prefix = "",
suffix = "",
className = "",
static: isStatic = false,
}: {
value: number;
duration?: number;
prefix?: string;
suffix?: string;
className?: string;
/** Render the value as plain text (no tick animation). Use for years,
dates, or any value that should not be perceived as "counting up".
See LAYOUT_AUDIT #11. */
static?: boolean;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [display, setDisplay] = useState(isStatic ? value : 0);
useEffect(() => {
// When `static` is set, the value is rendered as-is with no observer.
if (isStatic) return;
const el = ref.current;
if (!el) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// Hoisted to the effect scope so the cleanup below can cancel an
// in-flight frame on unmount / dep change (the IO callback's own
// return value is never used as a cleanup).
let raf = 0;
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
if (prefersReduced) {
setDisplay(value);
return;
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
// easeOutExpo
const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
setDisplay(Math.round(eased * value));
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
},
{ threshold: 0.4 }
);
io.observe(el);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}, [value, duration, isStatic]);
return (
<span ref={ref} className={`tabular-nums ${className}`}>
{prefix}
{display}
{suffix}
</span>
);
}