/** * # scroll-mrna.js * * Desktop-only mRNA explainer: sticky stage, gated visibility, and a * playhead map of fade / scale / stagger beats. * * Scroll *starts* each scene; the beats then run on their own clock * (`CONFIG.playback.mode = "play"`). Set it to "scrub" for the original * 1:1 scroll mapping. * * ## Introduction * * This module owns the long-form mRNA section. `gsap-module.js` skips * everything inside `[data-gsap="scroll-track"]`, so entrances here are * not double-bound. Sticky positioning stays in Webflow CSS — this file * never `pin`s. * * In "play" mode the step map is cut into scenes at * `CONFIG.playback.sceneBreaks`, each gap is clamped to * `CONFIG.playback.maxGap`, and each scene gets its own ScrollTrigger * that only flips its paused timeline to playing. Scrolling back up above * a scene's `start` rewinds that scene at `CONFIG.playback.reverseSpeed` * rather than cutting to its first frame. `CONFIG.scroll.coverage` and * `CONFIG.scrub` are ignored here — they apply to "scrub" mode only. * * Only one scene is ever live. Scenes share their nodes (a `cross` beat * re-uses a slot an earlier scene filled), so on every boundary crossing * the others are settled instantly to the state their own trigger implies * — earlier ones finished, later ones untouched. Without that, crossing * both `start` lines in one flick leaves two timelines writing opposite * values to the same nodes. See `syncScenes`. * * Each beat gates `visibility` / `opacity` / `pointer-events`. Layout is * captured once (position + insets + GSAP x/y/xPercent/yPercent) and * restored on every show/hide so fade/scale never overwrite placement. * Pixel lengths convert to rem from the root font-size. * * Desktop only: `(min-width: CONFIG.breakpoint)`. Below that, `matchMedia` * reverts inline tweens and Webflow CSS takes over. * * When `SensibleGSAP` is present, fade-in / scale-up duration, ease, blur, * y, and scale are reused unless overridden in this CONFIG. * * ## Setup * * 1. Load GSAP and ScrollTrigger. * 2. Load `gsap-module.js` (optional, recommended). * 3. Load this file. * 4. In Webflow, keep layout / position / translate on the nodes. * Do not restyle `data-gsap="scroll-track"` or `data-gsap="sticky-element"`. * * Markup (one block per instance) * * [data-gsap-scroll-mrna="component"] * [data-gsap="scroll-track"] tall scroll range * [data-gsap="sticky-element"] sticky stage (Webflow CSS) * [data-gsap-scroll-mrna=""] * * Default Webflow state (already in CSS): hidden / no pointer / opacity 0. * Active: visible / opacity 1 / pointer-events auto. * * `CONFIG.steps` keys are playhead seconds. A value is one action or an * array of parallel actions: * type: fade-in | fade-out | scale-up | scale-down * | stagger-in | stagger-out | cross * role: data-gsap-scroll-mrna value * holder: data-gsap-scroll-holder value * from / to / pin / size: cross only, see below * * `cross` swaps one slot between two states without moving it. It fades * `from` down and `to` up with an overlap, so the stage is never empty, * and it animates opacity only — no y, no scale, no blur. With * `pin: true` the `to` element is also translated onto the box `from` * occupies (same left edge, same centre line), so a short list lands * dead-centre where a long one was. The shift is a transform, so layout * is never touched — see `pinPair` for why that matters here. * `size: true` additionally copies the anchor's width and height, and is * off by default. Together these keep the two screens on one identical * composition. * * Later keys do not auto-reflow when you change a duration — shift them * by hand. * * ## Usage Example * *
*
*
*
*
*
*
*
*
*
*
*
*
* * SensibleScrollMrna.CONFIG.playback.maxGap = 0.25; // tighten entrance * SensibleScrollMrna.CONFIG.playback.reverseSpeed = 1.5; * SensibleScrollMrna.CONFIG.playback.starts = ["top 70%", "35% top"]; * SensibleScrollMrna.CONFIG.steps[1.2] = { type: "fade-in", role: "top-box-info-1" }; * SensibleScrollMrna.init(); * SensibleScrollMrna.refresh(); * * Public API: `window.SensibleScrollMrna` * { version, CONFIG, init, destroy, refresh, getInstances } * * ## Code * * Implementation follows. See Recommendations at the end of this file. * * @version 1.0.2 */ (function (global) { "use strict"; /* =========================================================================== * CONFIG — single object for quick edits * Durations in seconds. `steps` keys are timeline positions. * ======================================================================== */ var CONFIG = { autoInit: true, reducedMotion: true, breakpoint: 992, /** true = 1:1 with scroll. A number adds catch-up lag (e.g. 1). */ scrub: 1.5, /** * How the timeline is driven. * * "play" scroll only *starts* a scene. The beats then run at their * own speed, staggered, and finish whether you keep * scrolling or not. This is the client-approved behaviour. * "scrub" original 1:1 mapping of playhead to scroll position. * * `steps` stays the single source of truth in both modes. In "play" * mode the authored gaps are only used for *ordering*; the long holds * that exist purely to spread beats over a scrub distance are clamped * to `maxGap`, and the map is cut into scenes at `sceneBreaks`. */ playback: { mode: "play", /** * Largest gap (seconds) allowed between two consecutive beats. * This is the main tuning knob for "how fast does it all arrive". * Lower = tighter. 0 fires every beat in a scene at once, so the * per-item stagger is the only remaining offset. */ maxGap: 0.25, /** * Playhead times in `steps` where a new scroll-triggered scene * begins. Everything before the first break is scene 1, and so on. * 12.4 is the scene-1 teardown beat. */ sceneBreaks: [12.4], /** * ScrollTrigger `start` per scene, by index. The last entry repeats * if there are more scenes than starts. Turn on * `CONFIG.scroll.markers` to tune these against the real track. */ starts: ["top 70%", "35% top"], /** * Scroll distance between phases, as a multiplier. 1 leaves the * section exactly as authored; 0.7 is the client's "cut it by 30%" * on 2026-09-18 — both the wait between phase one and phase two and * the run-out after phase two. * * This is NOT a simple track-height cut. Every gap is bounded by a * scroll-position pair, and one end of each pair is measured against * the viewport rather than the track (`starts[0]` is "top 70%", and * the sticky releases one sticky-height before the track ends). * Those viewport terms do not shrink with the track, so scaling the * height alone lands at roughly -20% on the first gap and -49% on * the second. `scrollPlan` solves for the height AND the scene * starts together so each gap lands on the number asked for. * * The resolved numbers are on the instance as `scrollPlan` — read * them with `SensibleScrollMrna.getInstances()[0].scrollPlan` to * copy the height back into Webflow. */ gapScale: 0.7, /** * Scrolling back up above a scene's `start` plays that scene * backwards rather than snapping it to its initial frame, so the * stagger unwinds instead of every card blinking out together. * * `reverseSpeed` multiplies the rewind only. 1 rewinds at the same * pace it played; higher is snappier, which usually reads better * because the viewer is already moving away. */ reverse: true, reverseSpeed: 1.5, /** true = play once ever, never rewind. Overrides `reverse`. */ once: false, }, stagger: 0.12, gsapWait: { timeout: 15000, interval: 50, }, scroll: { start: "top top", /** Fallback if coverage cannot be measured. */ end: "bottom bottom", /** * Fraction of the track's sticky travel that drives the timeline. * 0.9 leaves the last 10% after the interaction so the sticky * does not release on the final frame. */ coverage: 0.8, markers: false, invalidateOnRefresh: true, }, defaults: { /** * `false`, not "auto", and that is the whole reverse bug. * * Scene 1 and scene 2 animate the SAME nodes — every `cross` beat * re-uses a slot scene 1 filled. Auto-overwrite resolves a collision * by killing the conflicting property on the other scene's tween, * permanently: the tween stays in its timeline minus that property. * Replay scene 1 after one fast flick and a card still moves and * unblurs but never fades back in, so it is invisible forever. That * is the "it breaks once and then stays broken" failure. * * Collisions are prevented structurally instead — see `syncScenes`, * which keeps exactly one scene live at a time — so there is nothing * left for overwriting to resolve. */ overwrite: false, force3D: true, skipSelector: ".w-dyn-bind-empty, .w-dyn-empty, .w-condition-invisible", }, /** * Local fallbacks. When SensibleGSAP is present, matching fadeIn / * scaleUp fields from that CONFIG are used unless you override here. */ fadeIn: { duration: 1.15, ease: "power3.out", blur: 12, y: 48, }, scaleUp: { duration: 1.15, ease: "power3.out", blur: 12, scale: 0.92, }, /** * Scene-to-scene swap. Both halves of a pair sit in the same box, so * this is opacity only — no y, no scale, no blur. `overlap` is the * fraction of the out-tween that elapses before the in-tween starts: * 0 = both fully cross (ghosting), 1 = sequential (black frame). * Anything in between keeps something on screen at all times. */ cross: { duration: 0.6, ease: "power2.inOut", overlap: 0.45, }, visibility: { hidden: "hidden", visible: "visible", }, opacity: { hidden: 0, visible: 1, }, pointerEvents: { hidden: "none", visible: "auto", }, /** * Captured absolute insets and translate x / y (px) are converted to * rem using the root (html) font-size. `auto`, %, rem, vw and the * original `position` value are left as-is. */ rem: { enabled: true, }, selectors: { component: '[data-gsap-scroll-mrna="component"]', track: '[data-gsap="scroll-track"]', sticky: '[data-gsap="sticky-element"]', /** * Cards inside an inputs-list slot. * [list] .slot.cc-mrna-cards-list > * */ listNested: ".slot.cc-mrna-cards-list > *", listSlot: ".slot.cc-mrna-cards-list", /** * Children of a list slot that hold space but have nothing to show. * Webflow's spacer divs sit between the cards, and staggering them * spends `CONFIG.stagger` on an empty frame each — six of them open * `inputs-list-2`, so that beat began with 0.72s of dead air before * the first card appeared. They keep their space (nothing here * touches their layout); they are simply not beats. */ listFiller: ".spacer-component", }, /** * Playhead map. Read top → bottom; each key is when that beat starts. * A single action is an object. Parallel beats use an array. * * type: "fade-in" | "fade-out" | "scale-up" | "scale-down" * | "stagger-in" | "stagger-out" | "cross" * role: data-gsap-scroll-mrna value * holder: data-gsap-scroll-holder value * from / to / pin / size: cross only * * Times below assume duration 1.15s and about 5 staggered children. * Shift a key to move that beat; later keys do not auto-reflow. */ steps: { /* 1. Top box, then its first info panel. */ 0: { type: "scale-up", role: "top-box" }, 1.2: { type: "fade-in", role: "top-box-info-1" }, /* 2. First device. */ 2.4: { type: "scale-up", role: "device-1" }, /* 3. First input list (staggered children), then multi arrow. */ 3.6: { type: "stagger-in", role: "inputs-list-1" }, 5.3: { type: "scale-up", role: "multi-arrow-1" }, /* 4. Single arrow, then a hold so list 2 has room to enter. */ 6.5: { type: "scale-up", role: "single-arrow-1" }, /* 5. Second input list — same stagger rules as list 1. */ 9.2: { type: "stagger-in", role: "inputs-list-2" }, /** * 6. Scene 2 — the same composition with different contents. * * Nothing is torn down and rebuilt: each slot crossfades its scene-1 * state into its scene-2 state, in place. The stage is never empty, * and no box moves or resizes. * * `pin: true` re-anchors the scene-2 element onto the box its * scene-1 partner occupies (same left edge, same centre line) so a * 2-card list lands dead-centre in the space a 6-card list defined. * Every pair is pinned: the measurement is taken from the scene-1 * element, so the worst a pin can do is reproduce the screen-1 * composition exactly, which is the one that was signed off. * * `size: true` additionally copies the anchor's width and height. * It is off by default — matching centres already handles the * vessel, which is the same drawing in both states. * * Beats are offset slightly so the swap reads left-to-right rather * than as one hard cut. */ 12.4: { type: "cross", from: "top-box-info-1", to: "top-box-info-2", pin: true }, 12.7: { type: "cross", from: "inputs-list-1", to: "inputs-list-3", pin: true }, 12.9: [ { type: "cross", from: "multi-arrow-1", to: "twice-arrow-1", pin: true }, { type: "cross", from: "device-1", to: "device-2", pin: true }, ], 13.1: { type: "cross", from: "single-arrow-1", to: "single-arrow-2", pin: true }, 13.3: { type: "cross", from: "inputs-list-2", to: "inputs-list-4", pin: true }, }, }; /* =========================================================================== * CONSTANTS * ======================================================================== */ var ATTR = { mrna: "data-gsap-scroll-mrna", holder: "data-gsap-scroll-holder", }; var SHOW_TYPES = { "fade-in": "fade", "scale-up": "scale", "stagger-in": "fade", }; /** ScrollTrigger `start` keywords as 0-1 fractions. */ var START_KEYWORDS = { top: 0, left: 0, center: 0.5, centre: 0.5, bottom: 1, right: 1, }; var VERSION = "1.0.2"; var LOADER_EVENT = "sensible:loader-done"; var instances = []; var media = null; var origins = new WeakMap(); var authorStylesCache = new WeakMap(); var pluginsRegistered = false; var gsapReadyTimer = null; var gsapReadyWaiters = []; var instanceSeq = 0; var loaderBound = false; /* =========================================================================== * RUNTIME * ======================================================================== */ function gsapRef() { return global.gsap; } function scrollTriggerRef() { var gsap = gsapRef(); var globals; if (global.ScrollTrigger) return global.ScrollTrigger; if (gsap && gsap.core && typeof gsap.core.globals === "function") { globals = gsap.core.globals(); if (globals && globals.ScrollTrigger) return globals.ScrollTrigger; } return null; } function libsReady() { return Boolean(gsapRef() && scrollTriggerRef()); } function registerPlugins() { var gsap = gsapRef(); var ScrollTrigger = scrollTriggerRef(); if (!gsap || !ScrollTrigger) return false; if (!pluginsRegistered) { gsap.registerPlugin(ScrollTrigger); pluginsRegistered = true; } return true; } function whenGsapReady(onReady) { var wait = CONFIG.gsapWait || {}; var started; var timeout; var interval; if (typeof onReady === "function") gsapReadyWaiters.push(onReady); if (libsReady()) { flushGsapReady(true); return; } if (gsapReadyTimer) return; started = Date.now(); timeout = Number.isFinite(wait.timeout) ? wait.timeout : 15000; interval = Number.isFinite(wait.interval) ? wait.interval : 50; gsapReadyTimer = setInterval(function () { if (libsReady()) { flushGsapReady(true); return; } if (Date.now() - started >= timeout) flushGsapReady(false); }, interval); } function flushGsapReady(ok) { var queue = gsapReadyWaiters.slice(); var i; gsapReadyWaiters = []; if (gsapReadyTimer) { clearInterval(gsapReadyTimer); gsapReadyTimer = null; } if (!ok) { if (global.console && console.error) { console.error("[SensibleScrollMrna] Timed out waiting for GSAP / ScrollTrigger."); } return; } registerPlugins(); for (i = 0; i < queue.length; i++) queue[i](); } /** * GSAP 3.15 can publish a timeline-backed trigger with end = 0. Refreshing * it once here avoids the later recursive refresh crash. */ function forceInitScrollTrigger(st) { if (!st || typeof st.refresh !== "function") return; try { if (!st.end) st.refresh(); } catch (err) { if (global.console && console.warn) { console.warn("[SensibleScrollMrna] ScrollTrigger init aborted.", err); } } } function queueRefresh() { if (global.SensibleGSAP && typeof global.SensibleGSAP.refresh === "function") { global.SensibleGSAP.refresh(); return; } var ST = scrollTriggerRef(); if (!ST || typeof ST.refresh !== "function") return; try { if (!ST.isRefreshing) ST.refresh(); } catch (err) { if (global.console && console.warn) { console.warn("[SensibleScrollMrna] ScrollTrigger.refresh aborted.", err); } } } function bindLoaderRefresh() { if (loaderBound || !document.addEventListener) return; loaderBound = true; document.addEventListener(LOADER_EVENT, queueRefresh); } function prefersReducedMotion() { return ( CONFIG.reducedMotion && typeof global.matchMedia === "function" && global.matchMedia("(prefers-reduced-motion: reduce)").matches ); } /* =========================================================================== * DOM * ======================================================================== */ function attr(el, name) { var value; if (!el || !el.getAttribute) return ""; value = el.getAttribute(name); return value ? value.trim() : ""; } function shouldSkip(el) { var sel; if (!el || el.nodeType !== 1) return true; sel = CONFIG.defaults.skipSelector; if (sel && el.closest && el.closest(sel)) return true; return false; } function uniqueNodes(nodes) { var out = []; var seen = []; var i; var node; for (i = 0; i < nodes.length; i++) { node = nodes[i]; if (!node || seen.indexOf(node) !== -1) continue; seen.push(node); out.push(node); } return out; } function qsa(scope, selector) { if (!scope || !scope.querySelectorAll || !selector) return []; return Array.prototype.slice.call(scope.querySelectorAll(selector)); } function belongsTo(el, component) { if (!el || !component) return false; if (el === component) return true; if (!el.closest) return false; return el.closest(CONFIG.selectors.component) === component; } function queryOwned(component, selector) { var nodes = []; if (!component || !selector) return nodes; if (component.matches && component.matches(selector)) nodes.push(component); nodes = nodes.concat(qsa(component, selector)); return uniqueNodes(nodes).filter(function (el) { return belongsTo(el, component) && !shouldSkip(el); }); } function firstOwned(component, selector) { var nodes = queryOwned(component, selector); return nodes.length ? nodes[0] : null; } function roleSelector(role) { return "[" + ATTR.mrna + '="' + role + '"]'; } function holderSelector(role) { return "[" + ATTR.holder + '="' + role + '"]'; } function collectComponents(scope) { var list = []; if (scope && scope.matches && scope.matches(CONFIG.selectors.component)) { list.push(scope); } if (scope && scope.querySelectorAll) { list = list.concat(qsa(scope, CONFIG.selectors.component)); } return uniqueNodes(list); } function listRootSelector() { return "[" + ATTR.mrna + '^="inputs-list-"]'; } function ownedByList(el, list) { var owner; if (!el || !list) return false; if (el === list) return false; if (!el.closest) return list.contains && list.contains(el); owner = el.closest(listRootSelector()); return owner === list; } function isListRoot(el) { return Boolean(el && el.matches && el.matches(listRootSelector())); } /** Layout-only child of a list slot: it holds space, it is not a beat. */ function isListFiller(el) { var selector = CONFIG.selectors.listFiller; if (!selector || !el || !el.matches) return false; try { return el.matches(selector); } catch (err) { return false; } } function pushListNode(out, el, list, component) { if (!el || el === list) return; if (shouldSkip(el) || !belongsTo(el, component)) return; if (isListRoot(el)) return; if (isListFiller(el)) return; if (!ownedByList(el, list)) return; out.push(el); } /** * Children that stagger inside an inputs-list. * .slot.cc-mrna-cards-list > * (also when the list IS the slot) * Falls back to the list's own children if those selectors miss. */ function collectListItems(list, component) { var cfg = CONFIG.selectors; var out = []; var i; var child; var nodes; if (!list) return out; if (list.matches && list.matches(cfg.listSlot)) { for (i = 0; i < list.children.length; i++) { pushListNode(out, list.children[i], list, component); } } nodes = qsa(list, cfg.listNested); for (i = 0; i < nodes.length; i++) { pushListNode(out, nodes[i], list, component); } if (!out.length) { for (i = 0; i < list.children.length; i++) { child = list.children[i]; if (shouldSkip(child) || !belongsTo(child, component)) continue; if (isListRoot(child)) continue; if (isListFiller(child)) continue; if (!ownedByList(child, list) && child.closest && child.closest(listRootSelector()) !== list) { continue; } out.push(child); } } return uniqueNodes(out); } /** * Resolve one role once. Any role whose wrap is an inputs-list root also * gets its staggerable children collected, whether it is driven by a * stagger beat or by a crossfade. */ function collectRole(component, roles, lists, name) { var wraps; var isList = false; var i; if (!name || roles[name]) return; wraps = queryOwned(component, roleSelector(name)); roles[name] = wraps; for (i = 0; i < wraps.length; i++) { if (isListRoot(wraps[i])) isList = true; } if (!isList) return; lists[name] = { wraps: wraps, items: uniqueNodes( wraps.reduce(function (acc, list) { return acc.concat(collectListItems(list, component)); }, []) ), }; } function collectParts(component) { var roles = {}; var holders = {}; var lists = {}; var entries = stepEntries(CONFIG.steps); var i; var j; var action; var name; for (i = 0; i < entries.length; i++) { for (j = 0; j < entries[i].actions.length; j++) { action = entries[i].actions[j]; if (!action) continue; if (action.holder) { name = action.holder; if (!holders[name]) holders[name] = queryOwned(component, holderSelector(name)); } collectRole(component, roles, lists, action.role); collectRole(component, roles, lists, action.from); collectRole(component, roles, lists, action.to); } } return { roles: roles, holders: holders, lists: lists }; } function allAnimatedNodes(parts) { var nodes = []; var key; var list; for (key in parts.roles) { if (parts.roles[key]) nodes = nodes.concat(parts.roles[key]); } for (key in parts.holders) { if (parts.holders[key]) nodes = nodes.concat(parts.holders[key]); } for (key in parts.lists) { list = parts.lists[key]; if (list && list.items) nodes = nodes.concat(list.items); } return uniqueNodes(nodes); } /* =========================================================================== * EFFECTS + ORIGINAL TRANSFORMS * ======================================================================== */ function finiteNumber(value, fallback) { var n = typeof value === "number" ? value : parseFloat(value); return Number.isFinite(n) ? n : fallback; } function round(value, places) { var factor = Math.pow(10, places || 0); return Math.round(value * factor) / factor; } function blurFilter(px) { var n = Number(px); if (!n) return "blur(0px)"; return "blur(" + n + "px)"; } /** * Prefer SensibleGSAP.CONFIG.fadeIn / scaleUp so this timeline stays * visually aligned with the rest of the site. Local CONFIG wins when set. */ function effectConfig(kind) { var shared = global.SensibleGSAP && global.SensibleGSAP.CONFIG; var fromModule = shared && (kind === "fade" ? shared.fadeIn : shared.scaleUp); var local = kind === "fade" ? CONFIG.fadeIn : CONFIG.scaleUp; return { duration: finiteNumber(local.duration, fromModule && fromModule.duration), ease: local.ease || (fromModule && fromModule.ease) || "power3.out", blur: finiteNumber(local.blur, fromModule && fromModule.blur), y: finiteNumber(local.y, fromModule && fromModule.y), scale: finiteNumber(local.scale, fromModule && fromModule.scale), }; } function inverseEase(ease) { if (typeof ease !== "string") return ease; if (ease.indexOf(".out") !== -1) return ease.replace(".out", ".in"); if (ease.indexOf(".in") !== -1) return ease.replace(".in", ".out"); return ease; } function remRootPx() { var doc = document.documentElement; var computed = typeof global.getComputedStyle === "function" ? global.getComputedStyle(doc) : null; var n = computed ? parseFloat(computed.fontSize) : 16; return n > 0 ? n : 16; } function pxToRemString(px) { var n = typeof px === "number" ? px : parseFloat(px); var rem; if (!Number.isFinite(n)) return px; rem = n / remRootPx(); if (Math.abs(rem) < 1e-6) return "0rem"; return Math.round(rem * 10000) / 10000 + "rem"; } /** * Convert a CSS length that is px (or unitless) to rem. Leave rem / % / * em / vw / vh / auto untouched. */ function cssLengthToRem(value) { var s; if (value == null) return value; if (typeof value === "number") return pxToRemString(value); s = String(value).trim(); if (!s || s.toLowerCase() === "auto" || s === "none") return s.toLowerCase() === "auto" ? "auto" : value; if (/px\s*$/i.test(s) || /^-?\d+(\.\d+)?$/.test(s)) return pxToRemString(s); return s; } function useRem() { return !(CONFIG.rem && CONFIG.rem.enabled === false); } function isAutoToken(value) { return String(value || "").trim().toLowerCase() === "auto"; } function inlineProp(el, prop) { var value = ""; if (!el || !el.style) return ""; if (typeof el.style.getPropertyValue === "function") { value = (el.style.getPropertyValue(prop) || "").trim(); } if (!value) value = String(el.style[prop] || "").trim(); return value; } function specificityOf(selector) { var s = String(selector || "").replace(/::?[a-z-]+(\([^)]*\))?/gi, ""); var ids = (s.match(/#[\w-]+/g) || []).length; var attrs = (s.match(/\[[^\]]+\]/g) || []).length; var classes = (s.match(/\.[\w-]+/g) || []).length; var tags = (s.replace(/#[\w-]+|\.[\w-]+|\[[^\]]+\]/g, "").match(/[a-z][\w-]*/gi) || []) .length; return ids * 10000 + (classes + attrs) * 100 + tags; } var AUTHOR_PROPS = ["position", "top", "right", "bottom", "left", "transform", "translate", "-webkit-transform" ]; /** * Same-origin cascaded values. Webflow CDN sheets are skipped (CORS). */ function authorStyles(el) { var found; var spec; var sheets; var i; if (authorStylesCache.has(el)) return authorStylesCache.get(el); found = {}; spec = {}; function consider(prop, value, selSpec) { if (!value) return; if (spec[prop] == null || selSpec >= spec[prop]) { spec[prop] = selSpec; found[prop] = value; } } function walk(rules) { var r; var j; var p; var parts; var sel; var selSpec; var k; var value; if (!rules) return; for (j = 0; j < rules.length; j++) { r = rules[j]; try { if (r.cssRules) { if (r.media && r.media.mediaText && global.matchMedia) { if (!global.matchMedia(r.media.mediaText).matches) continue; } walk(r.cssRules); continue; } if (!r.selectorText || !r.style) continue; parts = r.selectorText.split(","); for (p = 0; p < parts.length; p++) { sel = parts[p].trim(); if (!sel) continue; try { if (!el.matches(sel)) continue; } catch (err) { continue; } selSpec = specificityOf(sel); for (k = 0; k < AUTHOR_PROPS.length; k++) { value = (r.style.getPropertyValue(AUTHOR_PROPS[k]) || "").trim(); consider(AUTHOR_PROPS[k], value, selSpec); } } } catch (err) {} } } try { sheets = document.styleSheets; for (i = 0; i < sheets.length; i++) { try { walk(sheets[i].cssRules); } catch (err) {} } } catch (err) {} authorStylesCache.set(el, found); return found; } function typedCssText(el, prop) { var map; var v; var unit; var text; if (!el || typeof el.computedStyleMap !== "function") return ""; try { map = el.computedStyleMap(); v = map.get(prop); if (!v) return ""; if (typeof CSSKeywordValue !== "undefined" && v instanceof CSSKeywordValue) { return String(v.value || ""); } unit = v.unit; if (unit === "percent") return v.value + "%"; if (unit && unit !== "number" && unit !== "px" && v.value != null) { return v.value + unit; } text = typeof v.toString === "function" ? v.toString() : ""; if (text && text.indexOf("[object") === -1 && !/matrix/i.test(text)) return text; } catch (err) {} return ""; } function insetValue(value) { if (value == null) return value; if (isAutoToken(value)) return "auto"; return useRem() ? cssLengthToRem(value) : value; } function rectsClose(a, b) { if (!a || !b) return false; return ( Math.abs(a.top - b.top) < 0.6 && Math.abs(a.left - b.left) < 0.6 && Math.abs(a.width - b.width) < 0.6 && Math.abs(a.height - b.height) < 0.6 ); } function readPosition(el, computed) { var value = inlineProp(el, "position") || authorStyles(el).position; if (value) return value; value = computed && computed.getPropertyValue ? computed.getPropertyValue("position") : ""; return value || ""; } /** * Author inset while keeping position:absolute. A static clone makes * specified top/left compute as auto and drops the abspos box. */ function readBoxProp(el, computed, prop) { var inline = inlineProp(el, prop); var authored; var typed; var clone; var parent; var cs; var used; var before; var after; var position; if (inline) return inline; authored = authorStyles(el)[prop]; if (authored) return authored; typed = typedCssText(el, prop); if (typed && (isAutoToken(typed) || !/px\s*$/i.test(typed))) return typed; parent = el.parentNode; position = computed && computed.getPropertyValue ? computed.getPropertyValue("position") : ""; try { if (parent) { clone = el.cloneNode(false); clone.setAttribute("aria-hidden", "true"); clone.style.setProperty("visibility", "hidden", "important"); clone.style.setProperty("pointer-events", "none", "important"); if (position) clone.style.setProperty("position", position); parent.insertBefore(clone, el); cs = global.getComputedStyle(clone); used = (cs.getPropertyValue(prop) || "").trim(); if (isAutoToken(used)) { parent.removeChild(clone); return "auto"; } before = clone.getBoundingClientRect(); clone.style.setProperty(prop, "auto", "important"); after = clone.getBoundingClientRect(); parent.removeChild(clone); if (rectsClose(before, after)) return "auto"; if (used) return used; } } catch (err) {} used = computed && computed.getPropertyValue ? computed.getPropertyValue(prop) : ""; if (!used || isAutoToken(used)) return "auto"; return used; } function emptyTranslate() { return { x: 0, y: 0, xPercent: 0, yPercent: 0, xCss: "", yCss: "" }; } function applyAxisLength(out, axis, token) { var s = String(token || "").trim(); var n; if (!s || s.toLowerCase() === "none") return; n = parseFloat(s); if (!Number.isFinite(n)) return; if (/%\s*$/.test(s)) { out[axis + "Percent"] += n; return; } if (/px\s*$/i.test(s) || /^-?\d+(\.\d+)?$/.test(s)) { out[axis] += n; return; } out[axis + "Css"] = s; } function parseTranslateList(out, value, yIfMissing) { var parts; var xToken; var yToken; if (!value || value === "none") return; parts = String(value).trim().replace(/,/g, " ").split(/\s+/); xToken = parts[0]; yToken = parts.length > 1 ? parts[1] : yIfMissing ? xToken : ""; applyAxisLength(out, "x", xToken); if (yToken) applyAxisLength(out, "y", yToken); } function parseTransformFns(value) { var out = emptyTranslate(); var re = /(translate3d|translateX|translateY|translate)\(\s*([^)]+)\)/gi; var match; var fn; var args; var found = false; if (!value || value === "none" || /matrix(3d)?\(/i.test(value)) return null; while ((match = re.exec(value))) { found = true; fn = match[1].toLowerCase(); args = match[2].split(","); if (fn === "translatex") applyAxisLength(out, "x", args[0]); else if (fn === "translatey") applyAxisLength(out, "y", args[0]); else { applyAxisLength(out, "x", args[0]); if (args[1] != null) applyAxisLength(out, "y", args[1]); } } return found ? out : null; } /** * Author translate (px / rem / %), not the used matrix. That matrix is * pixels and would replace position:absolute insets if written back. */ function parseAuthorTranslate(el, computed) { var authored = authorStyles(el); var parsed; var out; var translate; var transform; translate = inlineProp(el, "translate") || authored.translate || ""; if (!translate && computed && computed.getPropertyValue) { translate = (computed.getPropertyValue("translate") || "").trim(); } if (translate && translate !== "none") { out = emptyTranslate(); parseTranslateList(out, translate, false); return out; } transform = inlineProp(el, "transform") || inlineProp(el, "-webkit-transform") || authored.transform || authored["-webkit-transform"] || ""; parsed = parseTransformFns(transform); if (parsed) return parsed; parsed = parseTransformFns(typedCssText(el, "transform")); return parsed; } function tweenLength(value) { if (value == null || value === "") return 0; if (typeof value === "string" && !/px\s*$/i.test(value) && !/^-?\d+(\.\d+)?$/.test(String( value).trim())) { return value; } return useRem() ? pxToRemString(value) : value; } function axisPx(value) { if (typeof value === "number") return finiteNumber(value, 0); if (typeof value === "string" && /rem\s*$/i.test(value)) { return finiteNumber(parseFloat(value) * remRootPx(), 0); } return finiteNumber(value, 0); } /** * Snapshot position + insets + translate together. Transform is an offset * on top of the abspos box, never a substitute for it. */ function captureOrigin(el) { var gsap = gsapRef(); var computed; var origin; var authorT; var x; var y; var xPercent; var yPercent; if (origins.has(el)) return origins.get(el); computed = typeof global.getComputedStyle === "function" ? global.getComputedStyle(el) : null; authorT = parseAuthorTranslate(el, computed); gsap.set(el, { x: "+=0", y: "+=0", xPercent: "+=0", yPercent: "+=0", scaleX: "+=0", scaleY: "+=0", rotation: "+=0", }); x = finiteNumber(gsap.getProperty(el, "x"), 0); y = finiteNumber(gsap.getProperty(el, "y"), 0); xPercent = finiteNumber(gsap.getProperty(el, "xPercent"), 0); yPercent = finiteNumber(gsap.getProperty(el, "yPercent"), 0); if (authorT) { if (authorT.xPercent) { xPercent = authorT.xPercent; x = authorT.xCss || authorT.x; } else if (authorT.xCss) { x = authorT.xCss; } else if (authorT.x) { x = authorT.x; } if (authorT.yPercent) { yPercent = authorT.yPercent; y = authorT.yCss || authorT.y; } else if (authorT.yCss) { y = authorT.yCss; } else if (authorT.y) { y = authorT.y; } } origin = { position: readPosition(el, computed), x: x, y: y, xPercent: xPercent, yPercent: yPercent, scaleX: finiteNumber(gsap.getProperty(el, "scaleX"), 1), scaleY: finiteNumber(gsap.getProperty(el, "scaleY"), 1), filter: computed && computed.filter && computed.filter !== "none" ? computed.filter : "none", top: readBoxProp(el, computed, "top"), right: readBoxProp(el, computed, "right"), bottom: readBoxProp(el, computed, "bottom"), left: readBoxProp(el, computed, "left"), }; origins.set(el, origin); return origin; } function originFor(el) { return origins.has(el) ? origins.get(el) : captureOrigin(el); } function filterWithBlur(el, amount) { var origin = originFor(el); var blur = finiteNumber(amount, 0); if (!blur) return origin.filter; if (origin.filter === "none") return blurFilter(blur); return origin.filter + " " + blurFilter(blur); } function tweenBase() { var vars = { overwrite: CONFIG.defaults.overwrite, force3D: CONFIG.defaults.force3D, immediateRender: false, }; var i; var source; var key; for (i = 0; i < arguments.length; i++) { source = arguments[i]; if (!source) continue; for (key in source) { if (Object.prototype.hasOwnProperty.call(source, key) && source[key] != null) { vars[key] = source[key]; } } } return vars; } function layoutVars(el) { var origin = originFor(el); var vars = {}; if (origin.position) vars.position = origin.position; if (origin.top != null) vars.top = insetValue(origin.top); if (origin.right != null) vars.right = insetValue(origin.right); if (origin.bottom != null) vars.bottom = insetValue(origin.bottom); if (origin.left != null) vars.left = insetValue(origin.left); if (origin.width != null) vars.width = insetValue(origin.width); if (origin.height != null) vars.height = insetValue(origin.height); return vars; } function transformVars(el) { var origin = originFor(el); return { x: tweenLength(origin.x), y: tweenLength(origin.y), xPercent: origin.xPercent, yPercent: origin.yPercent, }; } function gateVars(visible) { return { visibility: visible ? CONFIG.visibility.visible : CONFIG.visibility.hidden, pointerEvents: visible ? CONFIG.pointerEvents.visible : CONFIG.pointerEvents.hidden, }; } function placementVars(el, visible) { var vars = gateVars(visible); var box = layoutVars(el); var key; for (key in box) { if (Object.prototype.hasOwnProperty.call(box, key)) vars[key] = box[key]; } return vars; } function displayVars(visible, el) { var vars = el ? placementVars(el, visible) : gateVars(visible); vars.opacity = visible ? CONFIG.opacity.visible : CONFIG.opacity.hidden; return vars; } function applyPlacement(tl, nodes, visible, at) { var i; if (!nodes || !nodes.length) return; for (i = 0; i < nodes.length; i++) { tl.set(nodes[i], placementVars(nodes[i], visible), at); } } function hiddenVars(el, kind) { var fade = effectConfig("fade"); var scale = effectConfig("scale"); var origin = originFor(el); var vars = transformVars(el); vars.opacity = CONFIG.opacity.hidden; if (kind === "scale") { vars.scaleX = origin.scaleX * scale.scale; vars.scaleY = origin.scaleY * scale.scale; vars.filter = filterWithBlur(el, scale.blur); } else if (kind === "fade") { vars.y = tweenLength(axisPx(origin.y) + fade.y); vars.filter = filterWithBlur(el, fade.blur); } else if (kind === "plain") { /* Crossfade target: transparent but otherwise untouched, so the element never moves, scales or blurs on the way in. */ vars.scaleX = origin.scaleX; vars.scaleY = origin.scaleY; vars.filter = origin.filter; } return vars; } function visibleVars(el, kind) { var origin = originFor(el); var vars = transformVars(el); vars.opacity = CONFIG.opacity.visible; vars.filter = origin.filter; if (kind === "scale") { vars.scaleX = origin.scaleX; vars.scaleY = origin.scaleY; } return vars; } function firstShowKind(action) { if (!action || !action.type) return null; return SHOW_TYPES[action.type] || null; } function targetId(action) { if (action.holder) return "holder:" + action.holder; if (action.role) return "role:" + action.role; return ""; } function firstShowMap() { var map = {}; var entries = stepEntries(CONFIG.steps); var i; var j; var action; var id; var kind; for (i = 0; i < entries.length; i++) { for (j = 0; j < entries[i].actions.length; j++) { action = entries[i].actions[j]; /* A crossfade target must arrive with no offset at all. */ if (action && action.type === "cross" && action.to) { id = "role:" + action.to; if (map[id] == null) map[id] = "plain"; continue; } kind = firstShowKind(action); if (!kind) continue; id = targetId(action); if (id && map[id] == null) map[id] = kind; } } return map; } function hideAllInputLists(component) { var gsap = gsapRef(); var wraps = queryOwned(component, listRootSelector()); var i; var j; var items; for (i = 0; i < wraps.length; i++) { captureOrigin(wraps[i]); gsap.set(wraps[i], displayVars(false, wraps[i])); items = collectListItems(wraps[i], component); for (j = 0; j < items.length; j++) { captureOrigin(items[j]); gsap.set(items[j], displayVars(false, items[j])); gsap.set(items[j], hiddenVars(items[j], "fade")); } } } /** * Move `follower` onto the box `anchor` occupies, using a transform only. * * Nothing is taken out of flow and no inset is rewritten. That is * deliberate: the offsets in this row are hand-tuned against the current * flow — the vessel sits at a large negative `top` relative to its own * column — so lifting a hidden element out of flow collapses its * container, shortens the row and drags the whole composition out of * place. A transform shifts the painted result and reflows nothing. * * Left edges are aligned and centre lines are matched, which is the rule * the design uses: each list shares its partner's left edge and is * centred against the vessel. */ function pinPair(anchor, follower, matchSize) { var gsap = gsapRef(); var anchorRect; var followerRect; var origin; var dx; var dy; if (!anchor || !follower || anchor === follower) return; if (!anchor.getBoundingClientRect || !follower.getBoundingClientRect) return; anchorRect = anchor.getBoundingClientRect(); followerRect = follower.getBoundingClientRect(); if (!anchorRect.width && !anchorRect.height) return; if (!followerRect.width && !followerRect.height) return; dx = anchorRect.left - followerRect.left; dy = anchorRect.top + anchorRect.height / 2 - (followerRect.top + followerRect.height / 2); /* Added to the captured translate rather than replacing it, so any authored translate (the arrows carry `translate(0%, 50%)`) survives. xPercent / yPercent are left alone — the measured rect already includes them, so the delta stays exact. */ origin = originFor(follower); origin.x = tweenLength(axisPx(origin.x) + dx); origin.y = tweenLength(axisPx(origin.y) + dy); gsap.set(follower, transformVars(follower)); if (matchSize) { origin.width = anchorRect.width + "px"; origin.height = anchorRect.height + "px"; gsap.set(follower, { width: insetValue(origin.width), height: insetValue(origin.height), }); } } /** Run every `cross` beat that asked for `pin`. */ function pinPairs(parts) { var entries = stepEntries(CONFIG.steps); var i; var j; var k; var action; var anchors; var followers; for (i = 0; i < entries.length; i++) { for (j = 0; j < entries[i].actions.length; j++) { action = entries[i].actions[j]; if (!action || action.type !== "cross" || !action.pin) continue; anchors = nodesOf(parts.roles[action.from]); followers = nodesOf(parts.roles[action.to]); for (k = 0; k < followers.length && k < anchors.length; k++) { pinPair(anchors[k], followers[k], Boolean(action.size)); } } } } function prepareInitialState(component, parts) { var gsap = gsapRef(); var shown = firstShowMap(); var nodes = allAnimatedNodes(parts); var i; var el; var kind; var key; var list; for (i = 0; i < nodes.length; i++) captureOrigin(nodes[i]); /* Before anything is hidden, while every box still has its real measured size. */ pinPairs(parts); hideAllInputLists(component); for (key in parts.roles) { kind = shown["role:" + key] || "fade"; if (parts.lists[key]) kind = "wrap"; for (i = 0; i < parts.roles[key].length; i++) { el = parts.roles[key][i]; gsap.set(el, displayVars(false, el)); if (kind !== "wrap") gsap.set(el, hiddenVars(el, kind)); } } for (key in parts.holders) { kind = shown["holder:" + key] || "scale"; for (i = 0; i < parts.holders[key].length; i++) { el = parts.holders[key][i]; gsap.set(el, displayVars(false, el)); gsap.set(el, hiddenVars(el, kind)); } } for (key in parts.lists) { list = parts.lists[key]; for (i = 0; i < list.items.length; i++) { gsap.set(list.items[i], displayVars(false, list.items[i])); gsap.set(list.items[i], hiddenVars(list.items[i], "fade")); } } } function prepareFinalState(parts) { var gsap = gsapRef(); var nodes = allAnimatedNodes(parts); var i; var el; var origin; for (i = 0; i < nodes.length; i++) { el = nodes[i]; origin = captureOrigin(el); gsap.set(el, displayVars(true, el)); gsap.set(el, transformVars(el)); gsap.set(el, { scaleX: origin.scaleX, scaleY: origin.scaleY, filter: origin.filter, }); } } /* =========================================================================== * TIMELINE HELPERS * ======================================================================== */ function nodesOf(targets) { if (!targets) return []; return uniqueNodes(Array.isArray(targets) ? targets : [targets]); } function resolveNodes(parts, action) { if (action.holder) return nodesOf(parts.holders[action.holder]); if (action.role) return nodesOf(parts.roles[action.role]); return []; } function addShow(tl, nodes, kind, at) { var fade = effectConfig("fade"); var scale = effectConfig("scale"); var duration = kind === "scale" ? scale.duration : fade.duration; var ease = kind === "scale" ? scale.ease : fade.ease; var i; var el; if (!nodes.length) return false; applyPlacement(tl, nodes, true, at); for (i = 0; i < nodes.length; i++) { el = nodes[i]; tl.fromTo( el, hiddenVars(el, kind), tweenBase(visibleVars(el, kind), { duration: duration, ease: ease, }), at ); } return true; } function addHide(tl, nodes, kind, at) { var fade = effectConfig("fade"); var scale = effectConfig("scale"); var duration = kind === "scale" ? scale.duration : fade.duration; var ease = inverseEase(kind === "scale" ? scale.ease : fade.ease); var i; var el; if (!nodes.length) return false; for (i = 0; i < nodes.length; i++) { el = nodes[i]; tl.to( el, tweenBase(hiddenVars(el, kind), { duration: duration, ease: ease, }), at ); } applyPlacement(tl, nodes, false, at + duration); return true; } function addStaggerShow(tl, list, at) { var fade = effectConfig("fade"); var wraps = list && list.wraps ? list.wraps : []; var items = list && list.items ? list.items : []; var i; var el; var when; if (!wraps.length && !items.length) return false; if (wraps.length) { for (i = 0; i < wraps.length; i++) { tl.set(wraps[i], displayVars(true, wraps[i]), at); } } if (!items.length) { addShow(tl, wraps, "fade", at); return true; } for (i = 0; i < items.length; i++) { el = items[i]; when = at + i * CONFIG.stagger; tl.set(el, placementVars(el, true), when); tl.fromTo( el, hiddenVars(el, "fade"), tweenBase(visibleVars(el, "fade"), { duration: fade.duration, ease: fade.ease, }), when ); } return true; } function addStaggerHide(tl, list, at) { var fade = effectConfig("fade"); var wraps = list && list.wraps ? list.wraps : []; var items = list && list.items ? list.items : []; var i; var el; var lastAt = at; if (!wraps.length && !items.length) return false; if (!items.length) { tl.to( wraps, tweenBase({ opacity: CONFIG.opacity.hidden, duration: fade.duration, ease: inverseEase(fade.ease), }), at ); applyPlacement(tl, wraps, false, at + fade.duration); return true; } for (i = 0; i < items.length; i++) { el = items[items.length - 1 - i]; lastAt = at + i * CONFIG.stagger; tl.to( el, tweenBase(hiddenVars(el, "fade"), { duration: fade.duration, ease: inverseEase(fade.ease), }), lastAt ); tl.set(el, placementVars(el, false), lastAt + fade.duration); } if (wraps.length) { for (i = 0; i < wraps.length; i++) { tl.set(wraps[i], displayVars(false, wraps[i]), lastAt + fade.duration); } } return true; } /** * Swap one slot's contents in place. The outgoing state fades down while * the incoming state fades up, overlapping by `CONFIG.cross.overlap`, so * the stage is never empty. Opacity is the only thing that animates. */ function addCross(tl, parts, action, at) { var cfg = CONFIG.cross || {}; var duration = finiteNumber(cfg.duration, 0.6); var ease = cfg.ease || "power2.inOut"; var overlap = finiteNumber(cfg.overlap, 0.45); var outNodes = nodesOf(parts.roles[action.from]); var inNodes = nodesOf(parts.roles[action.to]); var inList = parts.lists[action.to]; var inAt; var i; var el; var vars; if (!outNodes.length && !inNodes.length) return false; if (overlap < 0) overlap = 0; if (overlap > 1) overlap = 1; inAt = at + duration * overlap; for (i = 0; i < outNodes.length; i++) { el = outNodes[i]; tl.to( el, tweenBase({ opacity: CONFIG.opacity.hidden, duration: duration, ease: ease }), at ); tl.set(el, placementVars(el, false), at + duration); } /* The incoming list's children were parked in the staggered hidden state at init. Restore them while the wrap is still transparent, so the wrap alone carries the fade. */ if (inList && inList.items) { for (i = 0; i < inList.items.length; i++) { tl.set(inList.items[i], placementVars(inList.items[i], true), at); tl.set(inList.items[i], visibleVars(inList.items[i], "plain"), at); } } for (i = 0; i < inNodes.length; i++) { el = inNodes[i]; vars = visibleVars(el, "plain"); vars.scaleX = originFor(el).scaleX; vars.scaleY = originFor(el).scaleY; tl.set(el, placementVars(el, true), inAt); tl.fromTo( el, { opacity: CONFIG.opacity.hidden }, tweenBase(vars, { duration: duration, ease: ease }), inAt ); } return true; } function applyAction(tl, parts, action, at) { var type = action && action.type; var list; if (!type) return; if (type === "cross") { addCross(tl, parts, action, at); return; } if (type === "stagger-in" || type === "stagger-out") { list = parts.lists[action.role]; if (type === "stagger-in") addStaggerShow(tl, list, at); else addStaggerHide(tl, list, at); return; } if (type === "fade-in") { addShow(tl, resolveNodes(parts, action), "fade", at); return; } if (type === "fade-out") { addHide(tl, resolveNodes(parts, action), "fade", at); return; } if (type === "scale-up") { addShow(tl, resolveNodes(parts, action), "scale", at); return; } if (type === "scale-down") { addHide(tl, resolveNodes(parts, action), "scale", at); } } /* =========================================================================== * STEPS OBJECT → TIMELINE * Keys are playhead seconds. Sort numerically so insertion order * never matters (integer keys would otherwise jump ahead of "1.2"). * ======================================================================== */ function stepEntries(steps) { var keys = Object.keys(steps || {}); var out = []; var i; var at; var value; for (i = 0; i < keys.length; i++) { at = parseFloat(keys[i]); if (!Number.isFinite(at)) continue; value = steps[keys[i]]; if (!value) continue; out.push({ at: at, actions: Array.isArray(value) ? value : [value], }); } out.sort(function (a, b) { return a.at - b.at; }); return out; } function playMode() { return !(CONFIG.playback && CONFIG.playback.mode === "scrub"); } function playOnce() { return Boolean(CONFIG.playback && CONFIG.playback.once); } /** * Force one scene to a settled state — finished (1) or untouched (0) — * instantly, with no callbacks. * * `progress(value, true)` suppresses the timeline's own callbacks so * this is safe to call from inside a ScrollTrigger callback, and the * pause stops a half-finished rewind from carrying on afterwards. * A scene that is already parked where it is wanted is left alone, so * an untouched timeline is never rendered just to confirm frame 0. */ function settleScene(tl, progress) { if (!tl) return; if (!tl.isActive() && tl.progress() === progress) { tl.timeScale(1); return; } tl.pause(); tl.timeScale(1); tl.progress(progress, true); } /** * A scene is a STATE, not a free-running animation: while one is live, * every earlier scene must already be finished and every later scene * must be untouched. * * Nothing enforced that before, and it is the second half of the * glitch. Both scenes hang off the same track, so a fast flick crosses * their two `start` lines within a frame or two: scene 1 could still be * mid-stagger while scene 2 had already crossed over to its own * content, and the two timelines then wrote opposite opacities to the * same nodes on the same tick. What you were left with is the mixed * composition — scene-2 headline sitting above a scene-1 input list. * * Each neighbour is settled from ITS OWN trigger position, not from * `live`'s index. The scenes share an `end`, so scrolling up across it * fires `onEnterBack` on both, and an index rule would let scene 1 wipe * scene 2 while the playhead is still far below scene 2's start. * `st.progress > 0` means "the scroll has passed this scene's start", * which is true no matter which callback happens to be running. */ function syncScenes(timelines, triggers, live) { var i; var st; for (i = timelines.length - 1; i >= 0; i--) { if (i === live) continue; st = triggers[i]; settleScene(timelines[i], st && st.progress > 0 ? 1 : 0); } } /** * Scroll only chooses a direction; the timeline keeps its own clock. * * `toggleActions: "... reset"` is what made a scroll-up look like a * glitch — reset lands on frame 0 in one tick, so an eight-card stagger * arrives (or leaves) all at once. Reversing replays the same beats * backwards, and picking the tween up from wherever it currently sits * means a scroll up followed by a scroll down never jumps. * * Every callback re-asserts the scene invariant first. `triggers` is * the live array from `bindComponent` and is still being filled while * the triggers are created, so it is read inside the callbacks rather * than captured up front. */ function sceneControls(timelines, triggers, index) { var tl = timelines[index]; var speed = finiteNumber(CONFIG.playback && CONFIG.playback.reverseSpeed, 1.5); var rewind = !playOnce() && !(CONFIG.playback && CONFIG.playback.reverse === false); if (!(speed > 0)) speed = 1; return { /** * Crossing `start` downwards. `onLeaveBack` normally rewinds the * scene on the way up, but it only fires if that boundary is * actually crossed — a fast flick, a jump to the top, an anchor * link or the browser restoring a scroll position can all skip it * and leave the timeline parked at the end, where `play()` is a * silent no-op and the entrance never runs again. * * So re-arm here instead of trusting the exit: if the scene is * already finished on the way in, start it over. */ onEnter: function () { syncScenes(timelines, triggers, index); tl.timeScale(1); if (rewind && tl.progress() >= 1) tl.restart(); else tl.play(); }, /** * Crossing `end` upwards, i.e. coming back into the scene from * below it. The scene has already been seen, so this only resumes * an interrupted play — it must never restart. */ onEnterBack: function () { syncScenes(timelines, triggers, index); tl.timeScale(1).play(); }, /** Crossing `start` upwards: unwind the beats at `reverseSpeed`. */ onLeaveBack: rewind ? function () { syncScenes(timelines, triggers, index); tl.timeScale(speed).reverse(); } : null, }; } /** * Cut the sorted step list into scenes at `playback.sceneBreaks`. A beat * whose time is >= a break opens the next scene. */ function sceneGroups(entries) { var raw = (CONFIG.playback && CONFIG.playback.sceneBreaks) || []; var breaks = raw .map(function (n) { return parseFloat(n); }) .filter(function (n) { return Number.isFinite(n); }) .sort(function (a, b) { return a - b; }); var groups = []; var current = []; var b = 0; var i; for (i = 0; i < entries.length; i++) { while (b < breaks.length && entries[i].at >= breaks[b]) { if (current.length) groups.push(current); current = []; b++; } current.push(entries[i]); } if (current.length) groups.push(current); return groups; } /** * Rebase a scene to 0 and clamp every gap to `maxGap`. Authored order and * simultaneity are preserved; only the dead scrub holds are removed. */ function compressGaps(entries) { var limit = finiteNumber(CONFIG.playback && CONFIG.playback.maxGap, 0.25); var out = []; var at = 0; var prev = null; var i; if (limit < 0) limit = 0; for (i = 0; i < entries.length; i++) { if (prev !== null) at += Math.min(entries[i].at - prev, limit); prev = entries[i].at; out.push({ at: at, actions: entries[i].actions }); } return out; } function sceneStart(index) { var starts = (CONFIG.playback && CONFIG.playback.starts) || []; if (!starts.length) return CONFIG.scroll.start; return starts[Math.min(index, starts.length - 1)]; } /** One half of a ScrollTrigger `start` as a 0-1 fraction. */ function startToken(token) { var n; if (Object.prototype.hasOwnProperty.call(START_KEYWORDS, token)) { return START_KEYWORDS[token]; } if (/^-?\d+(\.\d+)?%$/.test(token)) { n = parseFloat(token); return Number.isFinite(n) ? n / 100 : null; } return null; } /** * "35% top" -> { el: 0.35, vp: 0, vpToken: "top" }. * * `el` is how far down the TRACK the boundary sits, `vp` how far down * the VIEWPORT it is read against. Anything this cannot reason about * — px offsets, `+=`, a function — returns null, which switches the gap * scaling off rather than guessing at it. */ function startFractions(value) { var parts; var el; var vp; if (typeof value !== "string") return null; parts = value.trim().split(/\s+/); if (parts.length !== 2) return null; el = startToken(parts[0]); vp = startToken(parts[1]); if (el == null || vp == null) return null; return { el: el, vp: vp, vpToken: parts[1] }; } /** * Re-scale the section's scroll distance by `playback.gapScale`. * * Everything is done in viewport units, so the result is one height in * `svh` plus one start string per scene and stays correct at any window * size — no re-measuring on resize. * * k track height / viewport s sticky height / viewport * P each scene's start, relative to the track top * R the release point, k - s (where the sticky lets go) * * A scene's start is `el * k - vp`: the `el` half rides the track and * shrinks with it, the `vp` half does not. That is the whole reason * this is not a one-line height change. Solve the total first — * * (1 - el0) * k' - s + vp0 = g * ((1 - el0) * k - s + vp0) * * — then place every later scene so its gap from the one before it is * exactly `g` times the authored gap. Scene 1 keeps its authored start: * the client asked to shorten the waits, not to move the entrance. */ function scrollPlan(track, sticky, sceneCount) { var g = finiteNumber(CONFIG.playback && CONFIG.playback.gapScale, 1); var viewport = global.innerHeight || 0; var height = track && track.offsetHeight ? track.offsetHeight : 0; var stickyHeight = sticky && sticky.offsetHeight ? sticky.offsetHeight : viewport; var starts = []; var fractions = []; var points = []; var gaps = []; var parsed; var k; var s; var kNext; var span; var head; var point; var fraction; var i; if (!(g > 0) || g === 1) return null; if (!viewport || !height || !(sceneCount > 0)) return null; k = height / viewport; s = stickyHeight / viewport; for (i = 0; i < sceneCount; i++) { parsed = startFractions(sceneStart(i)); if (!parsed) return null; fractions.push(parsed); points.push(parsed.el * k - parsed.vp); } /* An entrance anchored at or past the end of the track leaves nothing to scale, and a track shorter than its own sticky never travels. */ if (fractions[0].el >= 1) return null; span = (1 - fractions[0].el) * k - s + fractions[0].vp; if (!(span > 0)) return null; kNext = (g * span + s - fractions[0].vp) / (1 - fractions[0].el); if (!(kNext > s)) return null; head = fractions[0].el * kNext - fractions[0].vp; starts.push(sceneStart(0)); for (i = 1; i < sceneCount; i++) { point = head + g * (points[i] - points[0]); fraction = (point + fractions[i].vp) / kNext; if (!(fraction >= 0) || fraction > 1) return null; starts.push(round(fraction * 100, 3) + "% " + fractions[i].vpToken); gaps.push(round((points[i] - points[i - 1]) * viewport, 0)); } gaps.push(round((k - s - points[points.length - 1]) * viewport, 0)); return { scale: g, starts: starts, heightSvh: round(kNext * 100, 3), /* Diagnostics: gap sizes in px, authored vs. applied. */ gapsBefore: gaps, gapsAfter: gaps.map(function (n) { return round(n * g, 0); }), }; } function buildTimeline(parts, entries, paused) { var gsap = gsapRef(); var tl = gsap.timeline({ paused: Boolean(paused), defaults: { overwrite: CONFIG.defaults.overwrite }, }); var i; var j; var step; for (i = 0; i < entries.length; i++) { step = entries[i]; tl.addLabel("beat-" + i, step.at); for (j = 0; j < step.actions.length; j++) { applyAction(tl, parts, step.actions[j], step.at); } } return tl; } /* =========================================================================== * INSTANCE * ======================================================================== */ function scrollCoverage() { var n = Number(CONFIG.scroll && CONFIG.scroll.coverage); if (!Number.isFinite(n) || n <= 0) return 1; if (n > 1) return 1; return n; } /** * Sticky travel is the track height minus the viewport. The timeline * only consumes `coverage` of that distance so the last slice of the * track can hold the final frame before CSS unsticks. */ function scrollEndFor(track) { var coverage = scrollCoverage(); var viewport = global.innerHeight || 0; var height = track && track.offsetHeight ? track.offsetHeight : 0; var travel = Math.max(0, height - viewport); if (coverage >= 1 || !travel) { return (CONFIG.scroll && CONFIG.scroll.end) || "bottom bottom"; } return "+=" + Math.round(travel * coverage); } function bindComponent(component) { var ScrollTrigger = scrollTriggerRef(); var track = firstOwned(component, CONFIG.selectors.track); var sticky = firstOwned(component, CONFIG.selectors.sticky); var parts = collectParts(component); var nodes = allAnimatedNodes(parts); var timelines = []; var triggers = []; var groups; var controls; var triggerVars; var plan; var tl; var trigger; var i; if (!track) { if (global.console && console.warn) { console.warn( '[SensibleScrollMrna] Missing data-gsap="scroll-track" inside the component.', component ); } return null; } if (!nodes.length) return null; if (prefersReducedMotion()) { prepareFinalState(parts); return { component: component, track: track, sticky: sticky, timelines: [], scrollTriggers: [], timeline: null, scrollTrigger: null, }; } groups = sceneGroups(stepEntries(CONFIG.steps)); /* Before anything is measured or hidden: shortening the track changes how far the section scrolls, not how anything inside it is laid out, so the pin measurements in `prepareInitialState` are unaffected either way — but the height is the one thing here that alters the page, so it goes first and alone. */ plan = playMode() ? scrollPlan(track, sticky, groups.length) : null; if (plan) track.style.height = plan.heightSvh + "svh"; prepareInitialState(component, parts); instanceSeq++; if (playMode()) { /** * One paused timeline per scene. Scroll only flips it to playing; * the beats then run on their own clock, so the entrance always * completes at the designed speed instead of tracking the wheel. */ /* Every scene is built before any trigger exists: `sceneControls` needs the whole list so a scene can settle its neighbours before it animates. */ for (i = 0; i < groups.length; i++) { timelines.push(buildTimeline(parts, compressGaps(groups[i]), true)); } for (i = 0; i < timelines.length; i++) { /* No `animation` / `toggleActions` here on purpose: those only offer play / pause / restart / reset, and the timeline is driven by hand so the rewind can run at its own speed. */ controls = sceneControls(timelines, triggers, i); triggerVars = { id: "sensible-scroll-mrna-" + instanceSeq + "-scene-" + (i + 1), trigger: track, start: plan ? plan.starts[i] : sceneStart(i), once: playOnce(), markers: Boolean(CONFIG.scroll.markers), invalidateOnRefresh: CONFIG.scroll.invalidateOnRefresh !== false, onEnter: controls.onEnter, onEnterBack: controls.onEnterBack, }; if (controls.onLeaveBack) triggerVars.onLeaveBack = controls.onLeaveBack; trigger = ScrollTrigger.create(triggerVars); /* Registered before `forceInitScrollTrigger`, which can refresh and fire a callback synchronously — `syncScenes` has to be able to see this trigger by then. */ triggers.push(trigger); forceInitScrollTrigger(trigger); } } else { tl = buildTimeline(parts, stepEntries(CONFIG.steps), false); trigger = ScrollTrigger.create({ id: "sensible-scroll-mrna-" + instanceSeq, trigger: track, start: CONFIG.scroll.start, end: function () { return scrollEndFor(track); }, animation: tl, scrub: CONFIG.scrub, markers: Boolean(CONFIG.scroll.markers), invalidateOnRefresh: CONFIG.scroll.invalidateOnRefresh !== false, }); forceInitScrollTrigger(trigger); timelines.push(tl); triggers.push(trigger); } return { component: component, track: track, sticky: sticky, scrollPlan: plan, timelines: timelines, scrollTriggers: triggers, timeline: timelines[0] || null, scrollTrigger: triggers[0] || null, }; } /* =========================================================================== * PUBLIC API * ======================================================================== */ function destroy() { if (media && media.revert) media.revert(); media = null; instances = []; origins = new WeakMap(); instanceSeq = 0; return api; } function init(root) { var pendingRoot = root; var gsap; var scope; var query; if (!libsReady()) { whenGsapReady(function () { init(pendingRoot); }); return api; } registerPlugins(); destroy(); bindLoaderRefresh(); gsap = gsapRef(); scope = root && root.nodeType === 1 ? root : document; query = "(min-width: " + CONFIG.breakpoint + "px)"; media = gsap.matchMedia(); media.add(query, function () { var components = collectComponents(scope); origins = new WeakMap(); instances = components.map(bindComponent).filter(Boolean); queueRefresh(); return function () { var i; /* The inline height is the only thing this file writes outside a GSAP tween, so matchMedia's revert cannot take it back. */ for (i = 0; i < instances.length; i++) { if (instances[i] && instances[i].scrollPlan && instances[i].track) { instances[i].track.style.height = ""; } } instances = []; origins = new WeakMap(); }; }); return api; } /** Public surface. steps keys are playhead seconds and do not auto-reflow. */ var api = { version: VERSION, CONFIG: CONFIG, init: init, destroy: destroy, refresh: function () { queueRefresh(); return api; }, getInstances: function () { return instances.slice(); }, }; global.SensibleScrollMrna = api; if (CONFIG.autoInit) { whenGsapReady(function () { function start() { var ST; if (!CONFIG.autoInit) return; ST = scrollTriggerRef(); if (ST && ST.isRefreshing) { setTimeout(start, 50); return; } init(document); } if (document.readyState === "complete") { setTimeout(start, 0); return; } if (global.addEventListener) { global.addEventListener("load", start, { once: true, passive: true }); } else { document.addEventListener("DOMContentLoaded", start, { once: true }); } }); } return api; })(typeof window !== "undefined" ? window : this); /** * ## Recommendations & Best Practices * * Performance * - In "play" mode `maxGap` is the one knob that sets how quickly a * scene arrives. Per-item `CONFIG.stagger` runs on top of it, so a * long list dominates the scene's real runtime. * - Beats are clamped, not re-authored. `CONFIG.steps` keys still read * as the scrub-era spacing — that is deliberate, so "scrub" mode * stays usable for debugging. * - A scene is driven by `onEnter` / `onLeaveBack` callbacks, not by * `animation` + `toggleActions`. toggleActions cannot rewind at a * different speed, and its `reset` lands on frame 0 in one tick, * which reads as a glitch on a staggered list. * - `coverage` / `scrub` apply to "scrub" mode only. * - Reuse `SensibleGSAP.CONFIG` tokens when that module is present so * durations stay consistent site-wide. * * Accessibility * - Desktop only (`min-width: CONFIG.breakpoint`). Below that, Webflow * CSS must present the same content without the scrub. * - Reduced motion reverts inline tweens. The CSS hidden/visible gate * must still make sense as a static stack. * * Maintainability * - `CONFIG.steps` keys are playhead seconds and do not auto-reflow. * Changing a duration means shifting later keys by hand. * - Adding a scene means adding a time to `playback.sceneBreaks` and a * matching entry to `playback.starts`. * - Roles map to `data-gsap-scroll-mrna`. A typo silently skips a beat. * - After the loader Flip, this file already refreshes. Call * `SensibleScrollMrna.refresh()` if you inject CMS nodes later. * * Pitfalls * - Scenes are not independent. They animate the same nodes, so any new * scene must be added to `sceneBreaks` AND `starts` together, and * tween `overwrite` must stay `false` — "auto" silently strips the * conflicting property out of the other scene's tween for good. * - `playback.gapScale` is the one thing here that writes to the track: * an inline `height` in `svh`, cleared again when matchMedia reverts. * It is the exception to the rule below because the Webflow attribute * that owns that height (`scroll-track-height-svh`) only offers preset * steps, and the cut needed is a fraction. Set `gapScale: 1` and put * `scrollPlan.heightSvh` into Webflow to hand it back. * - Do not `pin` the sticky in JS. Webflow `position: sticky` owns it. * `cross` + `pin` is unrelated to ScrollTrigger pinning — it only * re-anchors one element onto another's box. * - Do not restyle `scroll-track` / `sticky-element` in this file. * - Fade/scale must never write `x` / `y` / insets. Placement is * captured once and restored on every hide. `pinPair` is the one * deliberate exception: it adds a delta to the follower's captured * `x` / `y` before anything is hidden, and every later show/hide then * restores that shifted transform instead of the original one. * - `pinPair` must never change `position`, insets or flow. The column * offsets in this section are tuned against the current flow, so * taking a hidden element out of it shortens the row and moves the * vessel. Transform only. * - A pinned follower is measured once, at init. Call * `SensibleScrollMrna.init()` again if a slot's size can change after * load (late webfont, CMS injection). * - Reduced motion still reveals both halves of every pair at once. * The CSS static stack has to make sense on its own. */