// Motion utilities — scroll reveal + scrollspy + shared CSS for micro-interactions. // Loaded after theme.jsx, before components/home. const { useState: useS_m, useEffect: useE_m, useRef: useR_m } = React; // ─── useReveal ────────────────────────────────────────────── // Returns [ref, shown]. Adds a fade+rise when the element first scrolls in. // `delay` (ms) staggers siblings. Honors prefers-reduced-motion. function useReveal(delay = 0) { const ref = useR_m(null); const [shown, setShown] = useS_m(false); useE_m(() => { const el = ref.current; if (!el) return; const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) { setShown(true); return; } const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { const t = setTimeout(() => setShown(true), delay); io.unobserve(e.target); return () => clearTimeout(t); } }); }, { threshold: 0.12, rootMargin: "0px 0px -8% 0px" }); io.observe(el); return () => io.disconnect(); }, [delay]); return [ref, shown]; } // ─── Reveal wrapper ───────────────────────────────────────── // ... function Reveal({ children, delay = 0, y = 24, style = {}, as = "div", ...rest }) { const [ref, shown] = useReveal(delay); const Tag = as; return ( {children} ); } // ─── useScrollSpy ─────────────────────────────────────────── // Given an array of section ids, returns the id currently in view. function useScrollSpy(ids, offset = 120) { const [active, setActive] = useS_m(ids[0]); useE_m(() => { const handler = () => { const y = window.scrollY + offset; let current = ids[0]; for (const id of ids) { const el = document.getElementById(id); if (el && el.offsetTop <= y) current = id; } // Bottom of page → last section if (window.innerHeight + window.scrollY >= document.body.scrollHeight - 4) { current = ids[ids.length - 1]; } setActive(current); }; handler(); window.addEventListener("scroll", handler, { passive: true }); window.addEventListener("resize", handler); return () => { window.removeEventListener("scroll", handler); window.removeEventListener("resize", handler); }; }, [ids.join(","), offset]); return active; } // ─── CountUp number ───────────────────────────────────────── // Slides up + counts a step number (e.g. "01") when scrolled in. function CountNumber({ value, style = {} }) { const [ref, shown] = useReveal(0); const target = parseInt(value, 10); const [n, setN] = useS_m(0); useE_m(() => { if (!shown || isNaN(target)) return; let raf, start; const dur = 1200; const tick = (ts) => { if (!start) start = ts; const p = Math.min(1, (ts - start) / dur); const eased = 1 - Math.pow(1 - p, 3); setN(Math.round(eased * target)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [shown, target]); const text = isNaN(target) ? value : String(n).padStart(value.length, "0"); return ( {text} ); } // ─── Global CSS for micro-interactions ────────────────────── const MOTION_CSS = ` /* Primary/ghost buttons: sliding fill + press */ .casa-btn { position: relative; overflow: hidden; isolation: isolate; transition: color .25s ease, border-color .25s ease, transform .12s ease; } .casa-btn::after { content: ""; position: absolute; inset: 0; z-index: -1; transform: scaleX(0); transform-origin: left; transition: transform .32s cubic-bezier(0.16,1,0.3,1); } .casa-btn-primary::after { background: #ccc; } .casa-btn-primary:hover::after { transform: scaleX(1); } .casa-btn-ghost::after { background: #fff; } .casa-btn-ghost:hover { color: #000 !important; border-color: #fff !important; } .casa-btn-ghost:hover::after { transform: scaleX(1); } .casa-btn:active { transform: translateY(1px) scale(0.985); } .casa-btn .casa-arrow { display: inline-block; transition: transform .25s cubic-bezier(0.16,1,0.3,1); } .casa-btn:hover .casa-arrow { transform: translateX(4px); } /* Nav links: growing underline + active state */ .casa-nav-link { position: relative; transition: color .25s ease; } .casa-nav-link::after { content: ""; position: absolute; left: 0; right: 0; bottom: -7px; height: 1px; background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform .3s cubic-bezier(0.16,1,0.3,1); } .casa-nav-link:hover::after, .casa-nav-link[data-active="1"]::after { transform: scaleX(1); } /* Style tiles: arrow nudge handled inline; add subtle lift */ .casa-tile { transition: background .15s ease, border-color .15s ease, transform .15s ease; } .casa-tile:hover { transform: translateY(-2px); } `; Object.assign(window, { useReveal, Reveal, useScrollSpy, CountNumber, MOTION_CSS });