holding one
// hold screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall
// scrub true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight)
// build(tl, ctx) add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q }
// phone 'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build }
// rest(ctx) optional: anything extra the finished composition needs (most scenes need nothing)
// Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too.
Stage.scene = (root, opts = {}) => {
if (!root) return null;
const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts;
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
const q = (sel) => [...root.querySelectorAll(sel)];
const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); };
if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; }
const mm = gsap.matchMedia();
mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => {
const isPhone = c.conditions.phone;
const ctx = { root, stage, phone: isPhone, q };
const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build };
if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); }
root.classList.remove('is-rest');
root.classList.add('is-live');
root.style.setProperty('--hold', plan.hold);
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true,
onUpdate: (self) => {
root.style.setProperty('--p', self.progress.toFixed(4));
if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats)));
},
},
});
plan.build(tl, ctx);
// pad the timeline to exactly 1 so positions in build() read as fractions of the hold
if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration());
return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); };
});
Stage.scenes.push(mm);
return mm;
};
// Masked split text. Lines rise out of their own mask; words and characters are for short headings.
Stage.split = (el, type = 'lines') => {
if (!el) return [];
if (window.SplitText && live) {
const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false });
return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars;
}
// fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `${w}`).join(' ');
el.dataset.split = 'words';
}
return [...el.children];
};
// Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in.
Stage.ready = () => {
const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean);
return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); });
};
// Every on the page, started by
// the scene file of that id (assets/scenes/.js registers Scenes['']).
Stage.init = () => {
for (const el of document.querySelectorAll('.scene[data-scene]')) {
if (el.__scene) continue;
const make = window.Scenes[el.dataset.scene];
if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; }
let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {}
el.__scene = make(el, o) || true;
}
};
window.Scenes = window.Scenes || {};
window.Stage = Stage;
if (live) { window.addEventListener('load', () => Stage.ready()); }
})();