// scroll.jsx — Scroll-driven animation utilities // Includes: useScrollProgress, useElementProgress, Parallax, CountUp, // WordReveal, LineReveal, StickyHeading, ScrollProgressBar, ScaleOnScroll /* ───────── Hooks ───────── */ // 0..1 page scroll progress function useScrollProgress() { const [p, setP] = React.useState(0); React.useEffect(() => { const calc = () => { const h = document.documentElement; const max = (h.scrollHeight - h.clientHeight) || 1; setP(Math.max(0, Math.min(1, window.scrollY / max))); }; calc(); window.addEventListener('scroll', calc, { passive: true }); window.addEventListener('resize', calc); return () => { window.removeEventListener('scroll', calc); window.removeEventListener('resize', calc); }; }, []); return p; } // Per-element scroll progress: 0 when bottom of element enters viewport bottom, // 1 when top of element exits viewport top. Smooth across the whole transition. function useElementProgress(ref) { const [p, setP] = React.useState(0); React.useEffect(() => { if (!ref.current) return; const el = ref.current; const calc = () => { const r = el.getBoundingClientRect(); const vh = window.innerHeight || 1; const total = r.height + vh; const passed = vh - r.top; setP(Math.max(0, Math.min(1, passed / total))); }; calc(); window.addEventListener('scroll', calc, { passive: true }); window.addEventListener('resize', calc); return () => { window.removeEventListener('scroll', calc); window.removeEventListener('resize', calc); }; }, [ref]); return p; } /* ───────── Parallax ───────── */ function Parallax({ speed = 0.3, children, as: As = 'div', style, ...rest }) { const ref = React.useRef(null); const [y, setY] = React.useState(0); React.useEffect(() => { if (!ref.current) return; const el = ref.current; const onScroll = () => { const r = el.getBoundingClientRect(); const vh = window.innerHeight || 1; // Distance from element center to viewport center const center = r.top + r.height / 2 - vh / 2; setY(-center * speed); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, [speed]); return ( {children} ); } /* ───────── CountUp ───────── */ // Counts up from 0 to `to` once the element enters viewport. `duration` in ms. function CountUp({ to, duration = 1400, format = (n) => Math.round(n), prefix = '', suffix = '' }) { const ref = React.useRef(null); const [val, setVal] = React.useState(0); const [seen, setSeen] = React.useState(false); React.useEffect(() => { if (!ref.current || seen) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setSeen(true); const start = performance.now(); const tick = (t) => { const p = Math.min(1, (t - start) / duration); // ease-out cubic const eased = 1 - Math.pow(1 - p, 3); setVal(to * eased); if (p < 1) requestAnimationFrame(tick); else setVal(to); }; requestAnimationFrame(tick); io.disconnect(); } }); }, { threshold: 0.4 }); io.observe(ref.current); return () => io.disconnect(); }, [to, duration, seen]); return {prefix}{format(val)}{suffix}; } /* ───────── Word-by-word text reveal ───────── */ function WordReveal({ text, as: As = 'span', delay = 0, stagger = 40, className = '' }) { const ref = React.useRef(null); const [seen, setSeen] = React.useState(false); React.useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setTimeout(() => setSeen(true), delay); io.disconnect(); } }); }, { threshold: 0.2, rootMargin: '0px 0px -8% 0px' }); io.observe(ref.current); return () => io.disconnect(); }, [delay]); const words = text.split(' '); return ( {words.map((w, i) => ( {w} {i < words.length - 1 && '\u00A0'} ))} ); } /* ───────── Line reveal — for long paragraphs ───────── */ function LineReveal({ children, delay = 0, className = '' }) { const ref = React.useRef(null); const [seen, setSeen] = React.useState(false); React.useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setTimeout(() => setSeen(true), delay); io.disconnect(); } }); }, { threshold: 0.2, rootMargin: '0px 0px -5% 0px' }); io.observe(ref.current); return () => io.disconnect(); }, [delay]); return (
{children}
); } /* ───────── ScrollProgressBar (top of viewport) ───────── */ function ScrollProgressBar() { const p = useScrollProgress(); return (
); } /* ───────── ScaleOnScroll — element scales/fades based on its own progress ───────── */ function ScaleOnScroll({ children, from = 0.92, to = 1, style, as: As = 'div', ...rest }) { const ref = React.useRef(null); const p = useElementProgress(ref); // Scale up to peak at 50% progress then back down const t = 1 - Math.abs(p - 0.5) * 2; const scale = from + (to - from) * Math.max(0, t); return ( {children} ); } /* ───────── BgShift — color shifts based on element progress ───────── */ function MarqueeRibbon({ items, accent, speed = 18 }) { // Reusable marquee ribbon; for adding kinetic separators between sections const doubled = [...items, ...items]; return (
{doubled.map((it, i) => ( {it} ))}
); } /* ───────── Typing-style scroll reveal ───────── Splits text children into per-character spans and reveals them in sequence when the host enters the viewport. Handles nested React elements (e.g. ) so styling is preserved per character. */ function TypeReveal({ children, speed = 32, delay = 0, caret = true, className = '', as: As = 'span' }) { const ref = React.useRef(null); const [seen, setSeen] = React.useState(false); const [done, setDone] = React.useState(false); // Process children -> array of char spans with running index, total count const total = React.useMemo(() => { let n = 0; const walk = (node) => { if (node == null || typeof node === 'boolean') return; if (typeof node === 'string' || typeof node === 'number') { const s = String(node); for (const ch of s) { if (ch !== '\n') n++; } return; } if (Array.isArray(node)) { node.forEach(walk); return; } if (React.isValidElement(node)) { walk(node.props.children); return; } }; walk(children); return n; }, [children]); const rendered = React.useMemo(() => { let idx = 0; const render = (node, key = 'r') => { if (node == null || typeof node === 'boolean') return null; if (typeof node === 'string' || typeof node === 'number') { const s = String(node); const chars = []; for (const ch of s) { const i = idx++; chars.push( {ch === ' ' ? '\u00A0' : ch} ); } return chars; } if (Array.isArray(node)) return node.map((n, i) => render(n, `${key}-a${i}`)); if (React.isValidElement(node)) { const inner = render(node.props.children, `${key}-e`); return React.cloneElement(node, { key: `${key}-el`, ...node.props }, inner); } return node; }; return render(children); }, [children, seen, delay, speed]); React.useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setSeen(true); io.disconnect(); } }); }, { threshold: 0.18, rootMargin: '0px 0px -6% 0px' }); io.observe(ref.current); return () => io.disconnect(); }, []); React.useEffect(() => { if (!seen) return; const t = setTimeout(() => setDone(true), delay + total * speed + 400); return () => clearTimeout(t); }, [seen, delay, total, speed]); return ( {rendered} {caret && } ); } Object.assign(window, { TypeReveal }); Object.assign(window, { useScrollProgress, useElementProgress, Parallax, CountUp, WordReveal, LineReveal, ScrollProgressBar, ScaleOnScroll, MarqueeRibbon, });