// motion.jsx — scroll-reveal observer, counter animation, hero parallax // Pure vanilla hooks; no framer/popmotion. Lightweight and bounce-free. function useReveal() { // Watch the DOM for elements with `.reveal` and toggle `.is-visible` // when they enter the viewport. Re-scans after every React render via // a MutationObserver so dynamically-mounted content also gets picked up. React.useEffect(() => { if (typeof IntersectionObserver === "undefined") return; const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { e.target.classList.add("is-visible"); io.unobserve(e.target); } } }, { threshold: 0.12, rootMargin: "0px 0px -40px 0px" }); const scan = () => { document.querySelectorAll(".reveal:not(.is-visible)").forEach((el) => { // skip ones already being observed if (!el.__obs) { el.__obs = true; io.observe(el); } }); // map / coverage block — toggles is-visible on its container document.querySelectorAll(".map:not(.is-visible)").forEach((el) => { if (!el.__obs) { el.__obs = true; io.observe(el); } }); }; scan(); const mo = new MutationObserver(scan); mo.observe(document.body, { childList: true, subtree: true }); return () => { io.disconnect(); mo.disconnect(); }; }, []); } function useCounters() { // Animate elements with data-count="" from 0 → final when visible. // Uses the same approach as useReveal but kicks the count anim on visibility. React.useEffect(() => { if (typeof IntersectionObserver === "undefined") return; const animate = (el) => { const target = parseFloat(el.getAttribute("data-count")); if (isNaN(target)) return; const duration = parseInt(el.getAttribute("data-duration") || "1400", 10); const suffix = el.getAttribute("data-suffix") || ""; const prefix = el.getAttribute("data-prefix") || ""; const start = performance.now(); const ease = (t) => 1 - Math.pow(1 - t, 4); // ease-out quartic const tick = (now) => { const t = Math.min(1, (now - start) / duration); const v = Math.round(target * ease(t)); el.textContent = prefix + v + suffix; if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }; const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); } } }, { threshold: 0.4 }); const scan = () => { document.querySelectorAll("[data-count]:not(.is-counted)").forEach((el) => { el.classList.add("is-counted"); io.observe(el); }); }; scan(); const mo = new MutationObserver(scan); mo.observe(document.body, { childList: true, subtree: true }); return () => { io.disconnect(); mo.disconnect(); }; }, []); } function useHeroParallax() { // Subtle vertical parallax on the hero photo. Drives a CSS custom prop --py // so it can pause naturally if the element is hidden. React.useEffect(() => { let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const el = document.querySelector(".hero__media img"); if (el) { const y = window.scrollY; // Only translate while hero is in viewport — bail at 800px if (y < 800) { el.style.setProperty("--py", (y * 0.08) + "px"); } } ticking = false; }); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); } function useMotion() { useReveal(); useCounters(); useHeroParallax(); } Object.assign(window, { useReveal, useCounters, useHeroParallax, useMotion });