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