// Scroll-triggered reveal + subtle parallax — IntersectionObserver driven.
// Adds `.is-visible` to [data-reveal] elements when they enter the viewport,
// and drives `--scroll` on [data-parallax] elements so CSS can move them.
(function installReveal() {
if (window.__revealInstalled) return;
window.__revealInstalled = true;
// One-shot reveal: add .is-visible, keep it (no flicker on scroll back).
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
}
}
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
// rAF-driven parallax. Updates --scroll (0..1 across viewport) per element.
const parallaxEls = new Set();
let rafId = null;
function tick() {
rafId = null;
const vh = window.innerHeight || 800;
parallaxEls.forEach((el) => {
const r = el.getBoundingClientRect();
// Progress: 0 when element just entered from bottom, 1 when it leaves top.
const progress = 1 - (r.top + r.height * 0.5) / (vh + r.height * 0.5);
el.style.setProperty('--scroll', Math.max(0, Math.min(1, progress)).toFixed(3));
});
}
function onScroll() {
if (rafId == null) rafId = requestAnimationFrame(tick);
}
// Observe any matching elements already in DOM + watch for new ones.
function isInView(el) {
const r = el.getBoundingClientRect();
const vh = window.innerHeight || 800;
return r.bottom > 0 && r.top < vh * 0.92;
}
function scan(root = document) {
const process = (el) => {
if (el.classList.contains('is-visible')) return;
// If already in view at scan time, reveal immediately (no IO dependency)
if (isInView(el)) {
el.classList.add('is-visible');
} else {
io.observe(el);
}
};
if (root.nodeType === 1 && root.matches && root.matches('[data-reveal]:not(.is-visible)')) {
process(root);
}
if (root.nodeType === 1 && root.matches && root.matches('[data-parallax]')) {
parallaxEls.add(root);
}
if (root.querySelectorAll) {
root.querySelectorAll('[data-reveal]:not(.is-visible)').forEach(process);
root.querySelectorAll('[data-parallax]').forEach((el) => parallaxEls.add(el));
}
tick();
}
const mo = new MutationObserver((muts) => {
for (const m of muts) {
m.addedNodes.forEach((n) => {
if (n.nodeType === 1) scan(n);
});
}
});
function start() {
scan();
mo.observe(document.body, { childList: true, subtree: true });
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
// Belt-and-suspenders: re-scan repeatedly for first 2s in case
// React mounts after the initial scan. Cheap — scan is a qsa.
let ticks = 0;
const poll = setInterval(() => {
scan();
if (++ticks >= 20) clearInterval(poll); // stop after 2s
}, 100);
// Fallback on scroll (some browsers/iframes IO can be flaky)
window.addEventListener('scroll', () => scan(), { passive: true });
}
if (document.body) start();
else document.addEventListener('DOMContentLoaded', start);
})();
// Simple Reveal wrapper component — wraps children in a div with
// `data-reveal` so the observer above handles it.
function Reveal({ as: Tag = 'div', delay = 0, kind = 'up', className = '', children, ...rest }) {
const ref = React.useRef(null);
return (
{children}
);
}
// Stagger container: auto-delays direct [data-reveal] children.
function Stagger({ step = 80, start = 0, children, className = '', as: Tag = 'div', ...rest }) {
const childArr = React.Children.toArray(children);
return (
{childArr.map((child, i) =>
React.isValidElement(child)
? React.cloneElement(child, {
style: {
...(child.props.style || {}),
'--reveal-delay': `${start + i * step}ms`,
},
})
: child
)}
);
}
Object.assign(window, { Reveal, Stagger });