// hp-anim.jsx — shared animation hooks. // Exposes window.useReveal (fade/slide on scroll into view) and // window.useCountUp (animated number count with prefix/suffix support). // Respects prefers-reduced-motion. (function setupHpAnim() { const { useState, useEffect, useRef } = React; const prefersReduced = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; // --------------------------------------------------------- // useReveal — IntersectionObserver-based scroll reveal. // Returns [ref, isRevealed]. Apply ref to the element and toggle // the "is-revealed" class via isRevealed. // --------------------------------------------------------- function useReveal(opts) { const o = opts || {}; const ref = useRef(null); const [revealed, setRevealed] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; if (prefersReduced()) { setRevealed(true); return; } if (typeof IntersectionObserver === "undefined") { setRevealed(true); return; } const io = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) { setRevealed(true); io.disconnect(); } }); }, { threshold: o.threshold != null ? o.threshold : 0.15, rootMargin: o.rootMargin || "0px 0px -10% 0px", } ); io.observe(el); return () => io.disconnect(); }, []); return [ref, revealed]; } // --------------------------------------------------------- // useCountUp — animates the numeric portion of a value like // "$2M", "25", "2" from 0 → target with ease-out-cubic. // Keeps any prefix (e.g. "$") and trailing letters (e.g. "M") static. // Returns [ref, displayText]. // --------------------------------------------------------- function useCountUp(targetValue, duration) { const d = duration || 1600; const ref = useRef(null); const [text, setText] = useState(String(targetValue)); useEffect(() => { const el = ref.current; if (!el) return; const raw = String(targetValue); const m = raw.match(/^(\D*?)(\d+(?:\.\d+)?)(.*)$/); if (!m) { setText(raw); return; } const prefix = m[1] || ""; const numStr = m[2]; const suffix = m[3] || ""; const target = parseFloat(numStr); const decimals = (numStr.split(".")[1] || "").length; if (prefersReduced()) { setText(raw); return; } // Start at "prefix0suffix" const zero = (0).toFixed(decimals); setText(prefix + zero + suffix); if (typeof IntersectionObserver === "undefined") { setText(raw); return; } const io = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (!e.isIntersecting) return; let start = null; const step = (ts) => { if (start == null) start = ts; const p = Math.min((ts - start) / d, 1); const eased = 1 - Math.pow(1 - p, 3); const current = (eased * target).toFixed(decimals); setText(prefix + current + suffix); if (p < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); io.disconnect(); }); }, { threshold: 0.35 } ); io.observe(el); return () => io.disconnect(); }, [targetValue, d]); return [ref, text]; } // --------------------------------------------------------- // Helper: assemble className with optional reveal class + delay var. // Returns object: { className, style }. // --------------------------------------------------------- function revealProps(baseClass, revealed, opts) { const o = opts || {}; const variant = o.variant || "fade-up"; const cls = [baseClass || "", variant, revealed ? "is-revealed" : ""] .filter(Boolean).join(" "); const style = {}; if (o.delay != null) style["--hp-delay"] = o.delay + "ms"; if (o.style) Object.assign(style, o.style); return { className: cls, style }; } window.useReveal = useReveal; window.useCountUp = useCountUp; window.revealProps = revealProps; })();