// parallax.jsx — lightweight scroll-driven parallax utilities. // // Why not pure CSS animation-timeline scroll()? It's Chrome-only as of mid-2026 // and the user expects this to work in Safari/Firefox too. So: a single global // scroll listener feeds rAF-throttled updates to all subscribed callbacks. // Each callback writes a CSS transform — the browser composites it on the GPU. (function setupParallaxBus() { if (window.__parallaxBus) return; const bus = { scrollY: 0, subs: new Set(), raf: 0, }; function tick() { bus.scrollY = window.scrollY || window.pageYOffset || 0; bus.subs.forEach((fn) => { try { fn(bus.scrollY); } catch (e) {} }); bus.raf = 0; } function onScroll() { if (!bus.raf) bus.raf = requestAnimationFrame(tick); } window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll, { passive: true }); // Prime the bus once on first tick so subscribers that mount mid-page get a value. requestAnimationFrame(tick); window.__parallaxBus = bus; })(); // useParallax — subscribes to the bus, writes transform to a ref. // speed > 0 : element drifts DOWN relative to scroll (slower-than-scroll) // speed < 0 : element drifts UP relative to scroll (faster-than-scroll feel) // speed = 0 : pinned (no parallax) // `mode = 'fixed'` is for fixed-position layers (translates entirely by scrollY * speed). // `mode = 'flow'` is for in-flow elements (translates by relative offset from section center). function useParallax(speed = 0.3, mode = 'fixed') { const ref = React.useRef(null); React.useEffect(() => { if (typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches) { return undefined; } const bus = window.__parallaxBus; if (!bus) return undefined; let parentTop = 0; let parentH = 0; let viewportH = window.innerHeight; const measure = () => { const el = ref.current; if (!el || !el.parentElement) return; const r = el.parentElement.getBoundingClientRect(); parentTop = r.top + (window.scrollY || 0); parentH = r.height; viewportH = window.innerHeight; }; const update = (y) => { const el = ref.current; if (!el) return; let dy; if (mode === 'fixed') { dy = -y * speed; } else { // Progress: -1 when section is fully below viewport, +1 when fully above. const center = parentTop + parentH / 2; const rel = (y + viewportH / 2 - center) / (viewportH / 2 + parentH / 2); dy = rel * speed * 100; // up to ±speed*100 px of drift } el.style.transform = `translate3d(0, ${dy.toFixed(2)}px, 0)`; }; measure(); bus.subs.add(update); update(bus.scrollY); const ro = (typeof ResizeObserver !== 'undefined') ? new ResizeObserver(measure) : null; if (ro && ref.current?.parentElement) ro.observe(ref.current.parentElement); return () => { bus.subs.delete(update); if (ro) ro.disconnect(); }; }, [speed, mode]); return ref; } function Parallax({ speed = 0.3, mode = 'flow', children, style, ...rest }) { const ref = useParallax(speed, mode); return (
{children}
); } // StarField — globally pinned, multi-layer parallax dots that drift past as you scroll. // Three layers at different speeds give cheap depth without WebGL. function StarField() { const layer1Ref = useParallax(-0.10, 'fixed'); // back, smallest, slowest const layer2Ref = useParallax(-0.22, 'fixed'); // mid const layer3Ref = useParallax(-0.45, 'fixed'); // front, brightest, fastest return (