// 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 });