// Animation primitives for Roberto Grécia site — institutional, refined // Uses IntersectionObserver + CSS transitions. No external libraries. const { useEffect, useRef, useState } = React; /** * useReveal — adds `data-revealed="true"` to an element when it enters viewport. * Pair with CSS that transitions from a hidden state to a revealed state. */ function useReveal(options = {}) { const ref = useRef(null); const { threshold = 0.15, rootMargin = '0px 0px -60px 0px', once = true } = options; useEffect(() => { const el = ref.current; if (!el) return; // Respect reduced motion if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('data-revealed', 'true'); return; } const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { el.setAttribute('data-revealed', 'true'); if (once) io.unobserve(el); } else if (!once) { el.setAttribute('data-revealed', 'false'); } }); }, { threshold, rootMargin } ); io.observe(el); return () => io.disconnect(); }, []); return ref; } /** * Reveal — wrapper component that animates its children in. * Variants: 'up' (default), 'down', 'left', 'right', 'fade', 'scale'. * `delay` in ms. */ function Reveal({ children, as = 'div', variant = 'up', delay = 0, className = '', style = {}, ...rest }) { const ref = useReveal(); const Tag = as; return ( {children} ); } /** * RevealWords — splits a string of text into words and reveals them in sequence. * Preserves spaces. Use for headlines. */ function RevealWords({ text, delay = 0, stagger = 55, className = '', style = {}, wordStyle = {} }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('data-revealed', 'true'); return; } const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { el.setAttribute('data-revealed', 'true'); io.unobserve(el); } }); }, { threshold: 0.2, rootMargin: '0px 0px -40px 0px' } ); io.observe(el); return () => io.disconnect(); }, []); // Accept either string or array of segments (to preserve inline style like italic color) const segments = Array.isArray(text) ? text : [{ text }]; let wordIdx = 0; const out = []; segments.forEach((seg, segIdx) => { const words = seg.text.split(/(\s+)/); words.forEach((w, i) => { if (/^\s+$/.test(w)) { out.push({w}); } else if (w.length) { const d = delay + wordIdx * stagger; out.push( {w} ); wordIdx++; } }); // optional separator between segments (like
) if (seg.after === 'br') out.push(
); }); return ( {out} ); } /** * DrawRule — horizontal rule that draws itself left-to-right on reveal. */ function DrawRule({ color = 'currentColor', width = '60px', height = 2, delay = 0, style = {} }) { const ref = useReveal(); return (
); } /** * CountUp — animates a number from 0 to target once it enters viewport. */ function CountUp({ to, suffix = '', prefix = '', duration = 1400, className = '', style = {} }) { const ref = useRef(null); const [val, setVal] = useState(0); const started = useRef(false); useEffect(() => { const el = ref.current; if (!el) return; const run = () => { const start = performance.now(); const step = (now) => { const t = Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic setVal(Math.round(eased * to)); if (t < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); }; if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setVal(to); return; } const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !started.current) { started.current = true; run(); io.unobserve(el); } }); }, { threshold: 0.4 }); io.observe(el); return () => io.disconnect(); }, [to, duration]); return {prefix}{val}{suffix}; } /** * MeanderPattern — subtle SVG greek-key background. Very low opacity. * Meant to sit behind hero content as a watermark. */ function MeanderPattern({ color = '#B8892B', opacity = 0.06, style = {} }) { // Simple greek-key motif that tiles horizontally const pattern = ( ); return ( <> {pattern}
` )}")`, backgroundRepeat: 'repeat', opacity, pointerEvents: 'none', ...style, }} /> ); } // Expose globals so other babel scripts can use them Object.assign(window, { useReveal, Reveal, RevealWords, DrawRule, CountUp, MeanderPattern });