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