85 lines
2.2 KiB
TypeScript
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>
|
|
);
|
|
}
|