/* ========================================================================= motion.js — the cinematic motion layer (vanilla, no build step) • Lenis smooth scroll, loaded from CDN, synced to one rAF loop. • Word-by-word reveal for any element with [data-split] (the signature awwwards hero move). • IntersectionObserver scroll-reveals for [data-reveal] (replaces the old .reveal system; still honours .reveal as an alias + delay classes). All gated behind prefers-reduced-motion. ========================================================================= */ (function () { 'use strict'; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* ---- Lenis smooth scroll (CDN) --------------------------------- */ function initLenis() { if (reduceMotion || typeof Lenis === 'undefined') return null; const lenis = new Lenis({ duration: 1.6, // slower, dreamier glide easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo out smoothWheel: true, smoothTouch: false, // native touch scrolling is better }); function raf(time) { lenis.raf(time); requestAnimationFrame(raf); } requestAnimationFrame(raf); // anchor links use Lenis for the glide instead of the browser jump document.querySelectorAll('a[href^="#"]').forEach((a) => { a.addEventListener('click', (e) => { const id = a.getAttribute('href'); if (!id || id === '#') return; const target = document.querySelector(id); if (!target) return; e.preventDefault(); lenis.scrollTo(target, { offset: -80, duration: 1.8 }); }); }); return lenis; } /* ---- word-by-word split for [data-split] ----------------------- */ function splitWords() { document.querySelectorAll('[data-split]').forEach((el) => { // split on whitespace, keep original line breaks via the
in source const html = el.innerHTML; // wrap each run of non-tag, non-space characters in a span.word const wrapped = html .replace(/(<[^>]+>)/g, '\u0000$1\u0000') // protect existing tags .split('\u0000') .map((chunk) => chunk.startsWith('<') ? chunk : chunk.replace(/(\S+)/g, '$1') ) .join(''); el.innerHTML = wrapped; el.classList.add('is-split'); }); } /* ---- reveal on scroll via IntersectionObserver ----------------- */ function initReveals() { const items = document.querySelectorAll('[data-reveal], .reveal'); if (reduceMotion || !('IntersectionObserver' in window)) { items.forEach((el) => el.classList.add('is-in')); document.querySelectorAll('.word').forEach((w) => w.classList.add('is-in')); return; } const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const el = entry.target; el.classList.add('is-in'); // stagger any child .word spans inside a split element el.querySelectorAll(':scope > .word, :scope > span.word').forEach((w, i) => { w.style.transitionDelay = `${i * 0.05}s`; w.classList.add('is-in'); }); io.unobserve(el); }); }, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' } ); items.forEach((el) => io.observe(el)); // hero split words: reveal immediately on load (above the fold) document.querySelectorAll('[data-split] .word').forEach((w, i) => { w.style.transitionDelay = `${0.1 + i * 0.06}s`; requestAnimationFrame(() => w.classList.add('is-in')); }); } /* ---- boot ------------------------------------------------------- */ function boot() { splitWords(); initReveals(); initLenis(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();