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