// Smooth scroll engine + scroll-linked animation primitives // Inspired by acceler8-style buttery scroll feel. Keeps existing palette and type. // --------------------------------------------------------------------------- // Global scroll state. Uses Lenis if loaded; otherwise falls back to native. // --------------------------------------------------------------------------- let __lenis = null; const __subs = new Set(); let __rafId = 0; const __initLenis = () => { if (__lenis) return; if (typeof window === 'undefined') return; if (typeof window.Lenis === 'undefined') { // Fallback: still publish scrollY updates from native scroll const onScroll = () => __subs.forEach(fn => fn(window.scrollY)); window.addEventListener('scroll', onScroll, { passive: true }); return; } try { __lenis = new window.Lenis({ duration: 1.25, easing: t => Math.min(1, 1.001 - Math.pow(2, -11 * t)), smoothWheel: true, wheelMultiplier: 0.9, touchMultiplier: 1.4, lerp: 0.085, }); const raf = (time) => { __lenis.raf(time); __rafId = requestAnimationFrame(raf); }; __rafId = requestAnimationFrame(raf); __lenis.on('scroll', ({ scroll }) => { __subs.forEach(fn => fn(scroll)); }); } catch (e) { // ignore — fallback to native } }; const useScrollY = () => { const [y, setY] = React.useState(0); React.useEffect(() => { __initLenis(); const fn = (v) => setY(v); __subs.add(fn); return () => __subs.delete(fn); }, []); return y; }; // --------------------------------------------------------------------------- // LParallax — translates child on Y based on element position vs viewport. // speed: negative = moves up faster than scroll, positive = lags behind. // --------------------------------------------------------------------------- const LParallax = ({ children, speed = -0.12, className = '', style = {} }) => { const ref = React.useRef(null); React.useEffect(() => { __initLenis(); let raf = 0; const update = () => { raf = 0; const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const center = rect.top + rect.height / 2 - window.innerHeight / 2; el.style.transform = `translate3d(0, ${(center * speed).toFixed(2)}px, 0)`; }; const fn = () => { if (!raf) raf = requestAnimationFrame(update); }; __subs.add(fn); window.addEventListener('resize', fn); update(); return () => { __subs.delete(fn); window.removeEventListener('resize', fn); cancelAnimationFrame(raf); }; }, [speed]); return (
{children}
); }; // --------------------------------------------------------------------------- // LScrollScale — gently scales (and fades) an element as it enters viewport. // Used to make hero & capability canvases feel like they "settle" on arrival. // --------------------------------------------------------------------------- const LScrollScale = ({ children, from = 0.94, to = 1, className = '', style = {} }) => { const ref = React.useRef(null); React.useEffect(() => { __initLenis(); const update = () => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const vh = window.innerHeight; const t = Math.max(0, Math.min(1, 1 - (rect.top - vh * 0.15) / (vh * 0.55))); const s = from + (to - from) * t; el.style.transform = `scale(${s.toFixed(4)})`; el.style.opacity = (0.55 + 0.45 * t).toFixed(3); }; const fn = () => update(); __subs.add(fn); window.addEventListener('resize', update); update(); return () => { __subs.delete(fn); window.removeEventListener('resize', update); }; }, [from, to]); return (
{children}
); }; // --------------------------------------------------------------------------- // LScrollProgress — slim top progress bar. // --------------------------------------------------------------------------- const LScrollProgress = () => { const ref = React.useRef(null); React.useEffect(() => { __initLenis(); const update = () => { const max = document.documentElement.scrollHeight - window.innerHeight; const p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0; if (ref.current) ref.current.style.transform = `scaleX(${p.toFixed(4)})`; }; const fn = () => update(); __subs.add(fn); window.addEventListener('scroll', update, { passive: true }); update(); return () => { __subs.delete(fn); window.removeEventListener('scroll', update); }; }, []); return
; }; // --------------------------------------------------------------------------- // LMagnetic — subtle cursor-pull on buttons. // --------------------------------------------------------------------------- const LMagnetic = ({ children, strength = 0.22, className = '', style = {} }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const onMove = (e) => { const r = el.getBoundingClientRect(); const x = (e.clientX - (r.left + r.width / 2)) * strength; const y = (e.clientY - (r.top + r.height / 2)) * strength; el.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`; }; const onLeave = () => { el.style.transform = ''; }; el.addEventListener('mousemove', onMove); el.addEventListener('mouseleave', onLeave); return () => { el.removeEventListener('mousemove', onMove); el.removeEventListener('mouseleave', onLeave); }; }, [strength]); return ( {children} ); }; // --------------------------------------------------------------------------- // LStagger — fades+lifts children with staggered delays as the parent enters. // Children carry data-stagger; container watches with IntersectionObserver. // --------------------------------------------------------------------------- const LStagger = ({ children, delay = 70, threshold = 0.15, className = '', as: As = 'div', ...rest }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const items = el.querySelectorAll('[data-stagger]'); items.forEach(c => { c.style.opacity = '0'; c.style.transform = 'translateY(22px)'; c.style.transition = 'opacity 760ms cubic-bezier(.2,.8,.2,1), transform 760ms cubic-bezier(.2,.8,.2,1)'; c.style.willChange = 'opacity, transform'; }); const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { items.forEach((c, i) => { setTimeout(() => { c.style.opacity = ''; c.style.transform = ''; }, i * delay); }); io.disconnect(); } }, { threshold }); io.observe(el); // Failsafe — never leave content invisible const t = setTimeout(() => { items.forEach(c => { c.style.opacity = ''; c.style.transform = ''; }); }, 2800); return () => { io.disconnect(); clearTimeout(t); }; }, [delay, threshold]); return {children}; }; // --------------------------------------------------------------------------- // LMarquee — infinite horizontal scroll. Duplicates children twice for seam-free loop. // --------------------------------------------------------------------------- const LMarquee = ({ children, duration = 38, className = '', reverse = false }) => (
{children}
); // --------------------------------------------------------------------------- // LTilt — gentle 3D tilt on pointer-move (used on hero preview frame). // --------------------------------------------------------------------------- const LTilt = ({ children, max = 6, className = '', style = {} }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; let raf = 0, tx = 0, ty = 0, cx = 0, cy = 0; const loop = () => { cx += (tx - cx) * 0.08; cy += (ty - cy) * 0.08; el.style.transform = `perspective(1400px) rotateY(${cx.toFixed(3)}deg) rotateX(${cy.toFixed(3)}deg)`; raf = requestAnimationFrame(loop); }; const onMove = (e) => { const r = el.getBoundingClientRect(); const px = (e.clientX - (r.left + r.width / 2)) / (r.width / 2); const py = (e.clientY - (r.top + r.height / 2)) / (r.height / 2); tx = Math.max(-1, Math.min(1, px)) * max; ty = Math.max(-1, Math.min(1, -py)) * max * 0.55; }; const onLeave = () => { tx = 0; ty = 0; }; el.addEventListener('mousemove', onMove); el.addEventListener('mouseleave', onLeave); raf = requestAnimationFrame(loop); return () => { el.removeEventListener('mousemove', onMove); el.removeEventListener('mouseleave', onLeave); cancelAnimationFrame(raf); }; }, [max]); return
{children}
; }; // --------------------------------------------------------------------------- // LFloat — perpetual gentle floating motion (decorative cards). // --------------------------------------------------------------------------- const LFloat = ({ children, dx = 6, dy = 10, dur = 7, delay = 0, className = '', style = {} }) => (
{children}
); // --------------------------------------------------------------------------- // LScrollLine — vertical line that fills from 0..1 as section enters viewport. // Used as a delicate scroll indicator next to section headings. // --------------------------------------------------------------------------- const LScrollLine = ({ className = '', style = {} }) => { const ref = React.useRef(null); React.useEffect(() => { __initLenis(); const update = () => { const el = ref.current; if (!el) return; const host = el.parentElement?.parentElement || el.parentElement; if (!host) return; const r = host.getBoundingClientRect(); const vh = window.innerHeight; const t = Math.max(0, Math.min(1, (vh - r.top) / (r.height + vh * 0.4))); el.style.transform = `scaleY(${t.toFixed(4)})`; }; const fn = () => update(); __subs.add(fn); window.addEventListener('resize', update); update(); return () => { __subs.delete(fn); window.removeEventListener('resize', update); }; }, []); return (
); }; Object.assign(window, { useScrollY, LParallax, LScrollScale, LScrollProgress, LMagnetic, LStagger, LMarquee, LTilt, LFloat, LScrollLine, });