gclass-anims 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Listeners.js ADDED
@@ -0,0 +1,1464 @@
1
+ import gsap from 'gsap'
2
+ import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars } from './Animations'
3
+ import { customAnims } from './CustomAnims'
4
+ import { defaults, normalize } from './Config'
5
+ import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
6
+
7
+ // Prefix for SplitText text-reveal classes. Distinct from the raw `text-*`
8
+ // so it can't collide with Tailwind utility classes like `text-red-500`.
9
+ const TEXT_PREFIX = "spawn-text-"
10
+ const TEXT_PREFIX_LEN = TEXT_PREFIX.length
11
+
12
+ // The engine is fully config-driven. All animation definitions live in
13
+ // Config.js; here we just normalise them into the two internal views the
14
+ // machinery consumes (spawn/entrance + loop) plus the raw `all` list.
15
+ const { all: animAll, spawnConfigs, loopConfigs } = normalize(customAnims)
16
+
17
+ // --- Named onComplete handler registry -------------------------------------
18
+ // `on-<kind>-complete-<name>` classes resolve `<name>` to a function here
19
+ // (preferred) or to a global `window[<name>]` as a fallback. Register your
20
+ // handlers with `registerComplete(name, fn)` so the engine can find them
21
+ // without polluting the global scope.
22
+ const completeHandlers = new Map()
23
+ export function registerComplete(name, fn) {
24
+ if (typeof fn === "function") completeHandlers.set(name, fn)
25
+ return fn
26
+ }
27
+ export function resolveHandler(name) {
28
+ if (completeHandlers.has(name)) return completeHandlers.get(name)
29
+ if (typeof window !== "undefined" && typeof window[name] === "function") return window[name]
30
+ return null
31
+ }
32
+
33
+ export default function initListeners() {
34
+ gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
35
+
36
+ const registeredListeners = []
37
+ const onCompleteTweens = []
38
+ const addListener = (el, type, fn) => {
39
+ el.addEventListener(type, fn)
40
+ registeredListeners.push({ el, type, fn })
41
+ }
42
+ const readClassNumber = (el, prefix, fallback) => {
43
+ const match = [...el.classList].find(c => c.startsWith(prefix))
44
+ return match ? Number(match.slice(prefix.length)) : fallback
45
+ }
46
+ const getEase = (el) => {
47
+ const match = [...el.classList].find(c => c.startsWith("ease-"))
48
+ return match ? match.split("-")[1] : defaults.ease
49
+ }
50
+
51
+ // Reduced-motion support. `.reduced` is a per-element opt-out: when the
52
+ // OS has "reduce motion" enabled, any element carrying `.reduced` is left
53
+ // completely un-animated (its spawn/loop/click/scroll/setup all skip).
54
+ const reducedMotion = () =>
55
+ (typeof window !== "undefined" && window.matchMedia?.(`(prefers-reduced-motion: reduce)`)?.matches) ?? false
56
+ const isReduced = (el) => reducedMotion() && el.classList.contains("reduced")
57
+
58
+ // `.preserve` keeps an already-rendered element (e.g. one that persists
59
+ // in a shared layout across route changes) from being re-animated when
60
+ // the Listeners setup re-runs. The element is animated the first time it
61
+ // appears and tagged with data-gsap-preserved; on a later path change the
62
+ // tag survives on the persistent DOM node, so setup skips it.
63
+ // `.preserve` keeps an already-rendered element from being re-animated. It
64
+ // applies to the element AND its children: any preserved ancestor also
65
+ // suppresses animation on this node.
66
+ const isPreserved = (el) => {
67
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
68
+ if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
69
+ }
70
+ return false
71
+ }
72
+ const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
73
+
74
+ // `spawnConfigs` is derived from the config in Config.js (see top of
75
+ // file). Adding/removing an entry there automatically re-wires every
76
+ // spawn feature below: order, scroll, leave, appear and text variants.
77
+
78
+ // Leave animations derive from spawnConfigs so adding an entry here
79
+ // automatically enables its leave/exit reverse too (single source of truth).
80
+ const findSpawn = (el) => {
81
+ const direct = spawnConfigs.find(({ sel }) => el.matches?.(sel))
82
+ if (direct) return direct
83
+ const cls = [...el.classList].find(c => c.startsWith(TEXT_PREFIX))
84
+ if (!cls) return null
85
+ return spawnConfigs.find(({ sel }) => sel === "." + cls.slice(TEXT_PREFIX_LEN))
86
+ }
87
+
88
+ const isGhost = (el) => el?.dataset?.gsapGhost === "1"
89
+ // Ghosts (leave proxies) must be ignored by every observer, so strip all
90
+ // magic classes and tag them. Otherwise they get re-captured and re-animated.
91
+ const markGhost = (el) => {
92
+ el.classList.remove("leave", "appear")
93
+ spawnConfigs.forEach(({ sel }) => el.classList.remove(sel.slice(1)))
94
+ el.setAttribute("data-gsap-ghost", "1")
95
+ }
96
+
97
+ const leaveStates = new WeakMap()
98
+
99
+ const captureLeave = (node) => {
100
+ if (!node.classList?.contains("leave")) return
101
+ const config = findSpawn(node)
102
+ if (!config || config.typewriter) return
103
+ leaveStates.set(node, {
104
+ html: node.outerHTML,
105
+ rect: node.getBoundingClientRect(),
106
+ tween: node._spawnTween || node._scrollTween,
107
+ from: config.from,
108
+ ease: getEase(node),
109
+ parent: node.parentNode,
110
+ next: node.nextElementSibling,
111
+ margin: getComputedStyle(node).margin,
112
+ zIndex: getComputedStyle(node).zIndex,
113
+ })
114
+ }
115
+
116
+ const flipStates = new WeakMap()
117
+ // Elements currently being flipped (re-entrancy guard).
118
+ const flipping = new WeakSet()
119
+
120
+ // Document-relative bounds. getBoundingClientRect() is viewport-relative,
121
+ // so its values shift by window scroll; using document coords keeps the
122
+ // FLIP delta free of any scroll that happened between capture and play.
123
+ const flipPos = (node) => {
124
+ const r = node.getBoundingClientRect()
125
+ return { x: r.x + window.scrollX, y: r.y + window.scrollY, w: r.width, h: r.height }
126
+ }
127
+
128
+ // Capture the element's first (resting) bounds. A later layout change
129
+ // morphs from this snapshot to the live position — a vanilla FLIP.
130
+ const captureFlip = (node) => {
131
+ if (!node.classList?.contains("flip")) return
132
+ const config = findSpawn(node)
133
+ if (!config || config.typewriter) return
134
+ if (flipping.has(node)) return
135
+ flipStates.set(node, flipPos(node))
136
+ }
137
+
138
+ // Last = current bounds; Invert = delta to rewind to the captured
139
+ // bounds; Play = fromTo(transform) back to 0 so the element slides
140
+ // from its first position into its new layout spot.
141
+ const playFlip = (node) => {
142
+ // A new flip supersedes an in-flight one: just kill the old tween
143
+ // and clear its transform so the rect below reads the true layout,
144
+ // otherwise the leftover mid-flight transform compounds each time.
145
+ if (node._flipTween) {
146
+ node._flipTween.kill()
147
+ node._flipTween = null
148
+ gsap.set(node, { clearProps: "transform" })
149
+ flipping.delete(node)
150
+ }
151
+
152
+ const old = flipStates.get(node)
153
+ const cur = flipPos(node)
154
+ // Re-anchor the baseline to the current layout; if we have no
155
+ // previous state this is just an initial capture.
156
+ flipStates.set(node, cur)
157
+ if (!old) return
158
+
159
+ const dx = Math.round(old.x - cur.x)
160
+ const dy = Math.round(old.y - cur.y)
161
+ if (!dx && !dy) return
162
+
163
+ flipping.add(node)
164
+ node._flipTween = gsap.fromTo(node,
165
+ { x: dx, y: dy },
166
+ {
167
+ x: 0, y: 0,
168
+ duration: readClassNumber(node, "time-", defaults.effectDuration),
169
+ ease: getEase(node),
170
+ onComplete: () => {
171
+ flipping.delete(node)
172
+ node._flipTween = null
173
+ },
174
+ }
175
+ )
176
+ }
177
+
178
+ // Animate every captured `.flip` element under a scope whose layout just
179
+ // changed (called after reflow, so Flip reads the new bounds).
180
+ const animateFlip = (scope) => {
181
+ if (!scope) return
182
+ gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
183
+ .forEach((el) => { if (el.isConnected) playFlip(el) })
184
+ }
185
+
186
+
187
+ const playLeave = (node) => {
188
+ const snap = leaveStates.get(node)
189
+ leaveStates.delete(node)
190
+ if (!snap || node._leaving) return
191
+ node._leaving = true
192
+
193
+ // Hold the removed node's layout space during the leave animation so
194
+ // the content below doesn't jump up the instant it's removed. The
195
+ // node is re-attached as a fixed ghost (out of layout), so without
196
+ // this placeholder everything beneath snaps into place before the
197
+ // exit finishes. The spacer is dropped once the leave completes.
198
+ const placeSpacer = (snap) => {
199
+ if (!snap.parent || snap.parent.nodeType !== 1) return null
200
+ const spacer = document.createElement("div")
201
+ spacer.style.cssText = `box-sizing:border-box;width:${snap.rect.width}px;height:${snap.rect.height}px;`
202
+ if (snap.margin) spacer.style.margin = snap.margin
203
+ // The captured `next` sibling may itself have been removed by the
204
+ // time this runs (e.g. several siblings leave together). Only use
205
+ // it as an anchor if it's still a live child; otherwise append.
206
+ const next = snap.next && snap.next.parentNode === snap.parent ? snap.next : null
207
+ snap.parent.insertBefore(spacer, next)
208
+ return spacer
209
+ }
210
+
211
+ // True reverse of the real tween. If the node was already removed
212
+ // (external removal), re-attach that same element fixed at its last
213
+ // position so the reversed tween is actually visible. Observer-facing
214
+ // classes are stripped so the re-attach can't re-trigger enter/leave.
215
+ if (snap.tween) {
216
+ const reattached = !node.isConnected
217
+ const spacer = reattached ? placeSpacer(snap) : null
218
+ if (reattached) {
219
+ node.classList.remove("leave", "appear", "scroll", "scroll-progress")
220
+ node.style.cssText = `position:fixed;z-index:${snap.zIndex};left:${snap.rect.left}px;top:${snap.rect.top}px;
221
+ width:${snap.rect.width}px;height:${snap.rect.height}px;margin:0;`
222
+ document.body.appendChild(node)
223
+ }
224
+ snap.tween.reverse()
225
+ snap.tween.eventCallback("onReverseComplete", () => {
226
+ if (reattached) node.remove()
227
+ else node.style.display = "none"
228
+ spacer?.remove()
229
+ })
230
+ return
231
+ }
232
+
233
+ // Fallback: no tween captured -> clone a ghost and animate to the
234
+ // spawn's "from" state (best-effort reverse).
235
+ const ghost = document.createElement("div")
236
+ ghost.innerHTML = snap.html
237
+ const g = ghost.firstElementChild
238
+ g.classList.remove("leave", "appear", "scroll", "scroll-progress")
239
+ g.style.cssText = `position:fixed;z-index:${snap.zIndex};left:${snap.rect.left}px;top:${snap.rect.top}px;
240
+ width:${snap.rect.width}px;height:${snap.rect.height}px;margin:0;`
241
+ document.body.appendChild(g)
242
+ const spacer = placeSpacer(snap)
243
+
244
+ gsap.to(g, {
245
+ ...snap.from,
246
+ duration: defaults.effectDuration,
247
+ ease: snap.ease || defaults.ease,
248
+ onComplete: () => { g.remove(); spacer?.remove() },
249
+ })
250
+ }
251
+
252
+ const collectLeave = (node) => {
253
+ if (!node || node.nodeType !== 1) return []
254
+ if (node.classList?.contains("leave")) return [node]
255
+ return gsap.utils.toArray(node.querySelectorAll?.(".leave"))
256
+ }
257
+
258
+ // Refresh the cached rect to the element's RESTING position (after its
259
+ // spawn transform settles), so re-attaching the leave ghost doesn't snap.
260
+ const refreshLeaveRect = (el) => {
261
+ const s = leaveStates.get(el)
262
+ if (s) {
263
+ s.rect = el.getBoundingClientRect()
264
+ // The spawn tween is created AFTER captureLeave (which runs on
265
+ // node-insert). Pick it up here so a later leave can reverse the
266
+ // real tween (fading + counting back down) instead of a bare ghost.
267
+ s.tween = el._spawnTween || el._scrollTween || s.tween
268
+ }
269
+ }
270
+
271
+ // Selector covering every spawn/expand class plus its auto-generated
272
+ // `.spawn-text-*` variant. Queried fresh inside getOrderDelay because the
273
+ // captured set must always reflect the current DOM (dynamically added or
274
+ // SplitText text elements can otherwise be missed, breaking their order).
275
+ const orderSelector = () =>
276
+ spawnConfigs.map(({ sel }) => sel).join(",") + "," +
277
+ spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
278
+
279
+ const getOrderDelay = (el, priority) => {
280
+ const samepri = gsap.utils.toArray(orderSelector())
281
+ .filter((e) => {
282
+ if (!e.classList.contains("order")) return false
283
+ const match = [...e.classList].find(p => p.startsWith("priority-"))
284
+ return (match ? Number(match.split("-")[1]) : 0) === priority
285
+ })
286
+ let order = samepri.indexOf(el)
287
+ if (el.classList.contains("reverse")) {
288
+ order = samepri.length - 1 - order
289
+ }
290
+ return order / defaults.orderDivide
291
+ }
292
+
293
+ const readTiming = (el) => {
294
+ const priority = readClassNumber(el, "priority-", 0)
295
+ return {
296
+ delay: el.classList.contains("order")
297
+ ? getOrderDelay(el, priority)
298
+ : priority * defaults.spawnDelayMultiplier,
299
+ duration: readClassNumber(el, "time-", 1),
300
+ ease: getEase(el),
301
+ }
302
+ }
303
+
304
+ // --- on-<kind>-complete-* handling ------------------------------------
305
+ // Triggered when a spawn / loop / click tween finishes. The class names a
306
+ // function to call, or (via `-anim-<name>`) an animation to play once.
307
+ // on-spawn-complete-<fn> on-spawn-complete-anim-<anim>
308
+ // on-loop-complete-<fn> on-loop-complete-anim-<anim>
309
+ // on-click-complete-<fn> on-click-complete-anim-<anim>
310
+ const playNamed = (el, name) => {
311
+ const entry = animAll.find((a) => a.sel === "." + name)
312
+ if (!entry) return
313
+ // `.complete-time-N` / `.complete-delay-N` override the triggered
314
+ // animation's duration / delay. They apply to spawn-style entries
315
+ // (passed into `play`); loop entries define their own duration, so
316
+ // only the delay is applied to them.
317
+ const delay = readClassNumber(el, "complete-delay-", 0)
318
+ const dur = readClassNumber(el, "complete-time-", 1)
319
+ const tween = entry.play
320
+ ? entry.play(el, delay, dur, getEase(el))
321
+ : entry.build(el, readLoopCtx(el))
322
+ if (!tween) return
323
+ if (!entry.play) tween.delay(delay)
324
+ el[name] = tween
325
+ onCompleteTweens.push(tween)
326
+ }
327
+ const fireOnComplete = (el, kind) => {
328
+ const prefix = `on-${kind}-complete-`
329
+ const cls = [...el.classList].find((c) => c.startsWith(prefix))
330
+ if (!cls) return
331
+ const val = cls.slice(prefix.length)
332
+ if (val.startsWith("anim-")) playNamed(el, val.slice(5))
333
+ else {
334
+ const fn = resolveHandler(val)
335
+ if (fn) fn(el)
336
+ }
337
+ }
338
+
339
+ const scrollTriggers = []
340
+ const computeTo = (from) => {
341
+ const to = {}
342
+ for (const [key] of Object.entries(from)) {
343
+ if (key === "opacity") to[key] = 1
344
+ else if (key === "filter") to[key] = "blur(0px)"
345
+ else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
346
+ else to[key] = key.startsWith("scale") ? 1 : 0
347
+ }
348
+ return to
349
+ }
350
+
351
+ // SplitText: `.spawn-text-<spawn/expand>` splits the element's text into
352
+ // chars (or words/lines) and animates each part with the same `from`
353
+ // state as its base spawn config. Granularity is chosen via an extra
354
+ // `.words` / `.lines` class (default `chars`). Per-part stagger is set
355
+ // with `stagger-N`.
356
+ const textSplits = []
357
+ const splitCache = new WeakMap()
358
+ const RTL_RE = /[\u0590-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
359
+ const isRTLText = (el) => RTL_RE.test(el.textContent || "")
360
+ // Split granularity. Explicit `.lines` / `.words` override everything;
361
+ // `.letter` opts back into the per-character mode (the old
362
+ // default). Default (no class) is per-WORD: far fewer split nodes, so
363
+ // the per-part spawn is much cheaper to animate and paint.
364
+ const getGranularity = (el) => {
365
+ if (el.classList.contains("lines")) return "lines"
366
+ if (el.classList.contains("words")) return "words"
367
+ if (el.classList.contains("letter")) return "chars"
368
+ return "words"
369
+ }
370
+ // Cursive RTL scripts (Arabic/Persian) render each letter as a distinct
371
+ // glyph and join neighbours during text shaping. Naively splitting at
372
+ // char level puts each letter in its own span, breaking those joins so
373
+ // words look disconnected/isolated (and for single-word strings would
374
+ // collapse into one part). Instead we use GSAP's splitArabicText trick:
375
+ // split into words first, then re-wrap every letter in its own span,
376
+ // injecting Zero-Width-Joiners so neighbours stay connected while each
377
+ // letter remains individually animatable.
378
+ const RTL_NON_JOINABLE = /[اأإآدذرزوؤءة]/
379
+ const RTL_DIACRITICS = /[\u064B-\u065F\u0670]/g
380
+ // Spacing-only characters (the Persian half-space ZWNJ, a literal ZWJ and
381
+ // whitespace) must not become tween targets; they are kept as inert text
382
+ // nodes so the natural gap is preserved and never animated.
383
+ const RTL_JOIN_BREAK = /[\u200C\u200D\s]/
384
+ // A real Arabic/Persian joining letter. Any other visible character —
385
+ // Latin, digits, punctuation (، ؟ ؛ . ! …) — is NOT a joining letter: it
386
+ // must not give the preceding letter a trailing Zero-Width-Joiner (which
387
+ // would render it in its connecting form instead of its correct END form),
388
+ // but it should still be split into its own span so it animates too.
389
+ const RTL_LETTER = /[\u0621-\u064A\u066E-\u06D5\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
390
+ const getRTLCharSplit = (el) => new SplitText(el, {
391
+ type: "words",
392
+ linesClass: "gsap-line",
393
+ wordsClass: "gsap-word",
394
+ charsClass: "gsap-char",
395
+ onSplit(self) {
396
+ const ZWJ = "\u200D"
397
+ const connects = (s) => !RTL_NON_JOINABLE.test(s.replace(RTL_DIACRITICS, "").slice(-1))
398
+ self.chars.length = 0
399
+ self.words.forEach((wordEl) => {
400
+ const chars = Array.from(wordEl.textContent)
401
+ const segs = [] // { text, brk:true=spacing, letter:false=punctuation }
402
+ let i = 0
403
+ while (i < chars.length) {
404
+ const c = chars[i]
405
+ if (RTL_JOIN_BREAK.test(c)) {
406
+ segs.push({ text: c, brk: true })
407
+ i++
408
+ continue
409
+ }
410
+ if (!RTL_LETTER.test(c)) {
411
+ segs.push({ text: c, brk: false, letter: false })
412
+ i++
413
+ continue
414
+ }
415
+ let g = c
416
+ if (c === "\u0644" && i + 1 < chars.length) {
417
+ let j = i + 1
418
+ let d = ""
419
+ while (j < chars.length && RTL_DIACRITICS.test(chars[j])) d += chars[j++]
420
+ if (j < chars.length && /[\u0622\u0623\u0625\u0627]/.test(chars[j])) {
421
+ g += d + chars[j]
422
+ i = j
423
+ }
424
+ }
425
+ while (i + 1 < chars.length && RTL_DIACRITICS.test(chars[i + 1])) {
426
+ g += chars[i + 1]
427
+ i++
428
+ }
429
+ i++
430
+ segs.push({ text: g, brk: false, letter: true })
431
+ }
432
+ wordEl.textContent = ""
433
+ let prevConnectable = false
434
+ segs.forEach((seg, si) => {
435
+ if (seg.brk) {
436
+ // keep spacing as an inert text node: it preserves the
437
+ // natural gap and never becomes a tween target
438
+ wordEl.appendChild(document.createTextNode(seg.text))
439
+ prevConnectable = false
440
+ return
441
+ }
442
+ if (!seg.letter) {
443
+ // visible non-letter (punctuation/digit/…): animate it
444
+ // as its own span but it never joins, and it ends the
445
+ // current run so the next letter can't connect to it
446
+ const pEl = document.createElement("div")
447
+ pEl.style.display = "inline-block"
448
+ pEl.className = "gsap-char"
449
+ pEl.textContent = seg.text
450
+ wordEl.appendChild(pEl)
451
+ self.chars.push(pEl)
452
+ prevConnectable = false
453
+ return
454
+ }
455
+ const connectable = connects(seg.text)
456
+ let s = seg.text
457
+ if (prevConnectable) s = ZWJ + s
458
+ const nextIsLetter = si + 1 < segs.length && segs[si + 1].letter
459
+ if (nextIsLetter && connectable) s += ZWJ
460
+ const charEl = document.createElement("div")
461
+ charEl.style.display = "inline-block"
462
+ charEl.className = "gsap-char"
463
+ charEl.textContent = s
464
+ wordEl.appendChild(charEl)
465
+ self.chars.push(charEl)
466
+ prevConnectable = connectable
467
+ })
468
+ })
469
+ self.words.forEach((w) => w.replaceWith(...w.childNodes))
470
+ self.words.length = 0
471
+ },
472
+ })
473
+
474
+ const getSplit = (el, gran) => {
475
+ let s = splitCache.get(el)
476
+ const rtlChars = gran === "chars" && isRTLText(el)
477
+ const key = rtlChars ? "rtl-chars" : gran
478
+ if (!s || s.granularity !== key) {
479
+ s?.revert()
480
+ s = rtlChars
481
+ ? getRTLCharSplit(el)
482
+ : new SplitText(el, {
483
+ type: gran,
484
+ linesClass: "gsap-line",
485
+ wordsClass: "gsap-word",
486
+ charsClass: "gsap-char",
487
+ })
488
+ s.granularity = key
489
+ splitCache.set(el, s)
490
+ textSplits.push(s)
491
+ }
492
+ return s
493
+ }
494
+ const getParts = (el, gran) => {
495
+ const s = getSplit(el, gran)
496
+ const parts = (gran === "chars" && isRTLText(el)) ? (s.chars || []) : (s[gran] || [])
497
+ // Explicitly promote each part to its own compositing layer. Chromium
498
+ // does this automatically for animated elements; Firefox is
499
+ // conservative and otherwise repaints these inline-block parts on the
500
+ // main thread every frame, which is what makes split-text lag there.
501
+ for (let i = 0; i < parts.length; i++) {
502
+ parts[i].style.willChange = "transform, opacity"
503
+ }
504
+ return parts
505
+ }
506
+
507
+ // Collapse a split back into a single text node once the part-spawn
508
+ // finishes. Unless the author opts out with `.no-revert`, this frees the
509
+ // hundreds of per-letter elements so the browser stops reflowing them.
510
+ const revertSplit = (el) => {
511
+ if (el.classList.contains("no-revert")) return
512
+ const s = splitCache.get(el)
513
+ if (!s) return
514
+ splitCache.delete(el)
515
+ const idx = textSplits.indexOf(s)
516
+ if (idx !== -1) textSplits.splice(idx, 1)
517
+ s.revert()
518
+ }
519
+ // The whole point of `.time-X` on a `.spawn-text-X` element is that the
520
+ // FULL reveal (first part starting to last part finishing) takes X
521
+ // seconds, no matter how many chars/words/lines it got split into.
522
+ // GSAP staggered tweens actually finish at `duration + stagger * (n-1)`,
523
+ // so `dur` can't be handed straight to `duration` as before — instead we
524
+ // solve for `duration`/`stagger` together so they always sum to `dur`.
525
+ // An explicit `.stagger-N` class is honored as-is; only `duration` is
526
+ // back-solved in that case so the last part still lands on `dur`.
527
+ const playText = (el, from, delay, dur, ease) => {
528
+ const gran = getGranularity(el)
529
+ const parts = getParts(el, gran)
530
+ if (!parts.length) return
531
+ const requestedStagger = readClassNumber(el, "stagger-", null)
532
+ let stagger, duration
533
+ if (requestedStagger != null) {
534
+ // Explicit stagger: honor it as-is, back-solve duration so the
535
+ // last part still lands on `dur` (floored so motion stays visible
536
+ // even if that pushes the true total slightly past `dur`).
537
+ stagger = requestedStagger
538
+ duration = Math.max(dur - stagger * (parts.length - 1), defaults.minTextPartDuration)
539
+ } else {
540
+ // No explicit stagger: keep each part's own duration a real,
541
+ // visible chunk of time (not shrinking toward 0 as part count
542
+ // grows) and shrink the STAGGER instead, so parts overlap more
543
+ // as there are more of them but the whole reveal still finishes
544
+ // at `dur`.
545
+ duration = Math.min(dur, Math.max(dur / 3, defaults.minTextPartDuration))
546
+ stagger = parts.length > 1 ? (dur - duration) / (parts.length - 1) : 0
547
+ }
548
+ return gsap.fromTo(parts, { ...from }, {
549
+ ...computeTo(from), ease, duration, delay, stagger,
550
+ onComplete: () => {
551
+ if (el.classList.contains("leave")) refreshLeaveRect(el)
552
+ revertSplit(el)
553
+ fireOnComplete(el, "spawn")
554
+ },
555
+ })
556
+ }
557
+
558
+ // Per-letter typewriter: split into chars and reveal each one in place,
559
+ // one after another (stagger) so it reads like being typed, unlike the
560
+ // single-stream TextPlugin `typewriter`. Already-typed chars stay visible.
561
+ const playTypewriterSplit = (el, delay, dur, ease) => {
562
+ const gran = getGranularity(el)
563
+ const parts = getParts(el, gran)
564
+ if (!parts.length) return
565
+ const perChar = dur / Math.max(parts.length, 1)
566
+ return gsap.fromTo(parts, { opacity: 0 }, {
567
+ opacity: 1,
568
+ ease,
569
+ duration: Math.min(defaults.typewriterSplitCharDuration, perChar),
570
+ delay,
571
+ stagger: perChar,
572
+ onComplete: () => revertSplit(el),
573
+ })
574
+ }
575
+
576
+ const textClsFor = (el) => [...el.classList].find(c => c.startsWith(TEXT_PREFIX))
577
+ const isTextElement = (el) => {
578
+ const cls = textClsFor(el)
579
+ if (!cls) return false
580
+ return !!spawnConfigs.find(({ sel }) => sel === "." + cls.slice(TEXT_PREFIX_LEN))
581
+ }
582
+ // Sticky-pin: hold the element fixed to the viewport across a scroll
583
+ // range. The range is `progress-start-N` -> `progress-end-N` when those
584
+ // classes are present (N = how many % into view to engage, and how far
585
+ // out of view to release), else the full `top top` -> `bottom bottom`.
586
+ const setupPin = (el) => {
587
+ if (!el.classList.contains("pin") || el.dataset.gsapPinned) return
588
+ const clamp = (n) => Math.min(100, Math.max(0, n))
589
+ const startClass = readClassNumber(el, "progress-start-", null)
590
+ const endClass = readClassNumber(el, "progress-end-", null)
591
+ const start = startClass != null ? `top ${clamp(100 - startClass)}%` : "top top"
592
+ const end = endClass != null ? `top ${clamp(100 - endClass)}%` : "bottom top"
593
+ const t = ScrollTrigger.create({
594
+ trigger: el,
595
+ start,
596
+ end,
597
+ pin: true,
598
+ pinSpacing: true,
599
+ anticipatePin: 1,
600
+ })
601
+ el.dataset.gsapPinned = "1"
602
+ scrollTriggers.push(t)
603
+ }
604
+
605
+ // Pins inject spacers that shift everything below them, so they MUST be
606
+ // created before any scroll/scroll-progress trigger measures its position.
607
+ // Setting them up here (before the trigger pass below) keeps offsets correct
608
+ // and lets the single ScrollTrigger.refresh() at the end reconcile layout.
609
+ gsap.utils.toArray(".pin").forEach(setupPin)
610
+
611
+ // Scroll-driven extras — class-driven ScrollTrigger behaviours that don't
612
+ // fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
613
+ // .parallax-N - element drifts relative to scroll. N is a
614
+ // speed factor: 1 = static, <1 = slower,
615
+ // >1 = faster (opposite travel direction).
616
+ // .progress-bar/.scroll-fill - fill 0->100% across a scroll range
617
+ // (scaleX, anchored left). progress-start-N /
618
+ // progress-end-N / progress-reverse honored.
619
+ // .scroll-fade-bg - lerp background-position across scroll
620
+ // (needs a background larger than the box).
621
+ // .scroll-horizontal - pinned section that pans its `.scroll-track`
622
+ // child left across the pinned range.
623
+ // (.clip-reveal and .curtain-* are spawn classes defined in Config.js,
624
+ // so they flow through the normal spawn/scroll/appear/leave machinery.)
625
+ const setupScrollDriven = (el) => {
626
+ if (el.dataset?.gsapScrollDriven) return
627
+ el.dataset.gsapScrollDriven = "1"
628
+ if (isReduced(el)) return
629
+ const cls = [...el.classList]
630
+ const clamp = (n) => Math.min(100, Math.max(0, n))
631
+
632
+ const parallaxCls = cls.find((c) => c.startsWith("parallax-"))
633
+ if (parallaxCls) {
634
+ const factor = parseFloat(parallaxCls.slice("parallax-".length)) || 1
635
+ if (factor === 1) return
636
+ const amt = (factor - 1) * 50
637
+ const t = gsap.fromTo(el,
638
+ { yPercent: -amt },
639
+ {
640
+ yPercent: amt, ease: "none",
641
+ scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true },
642
+ }
643
+ )
644
+ scrollTriggers.push(t.scrollTrigger)
645
+ return
646
+ }
647
+
648
+ if (el.classList.contains("progress-bar") || el.classList.contains("scroll-fill")) {
649
+ const startClass = readClassNumber(el, "progress-start-", null)
650
+ const endClass = readClassNumber(el, "progress-end-", null)
651
+ // `.progress-reverse` runs the fill in reverse (full -> empty).
652
+ // GSAP's ScrollTrigger has no `reversed` config; swap the from/to
653
+ // so the scrub maps in the opposite direction instead.
654
+ const reverse = el.classList.contains("progress-reverse")
655
+ const t = gsap.fromTo(el,
656
+ { scaleX: reverse ? 1 : 0 },
657
+ {
658
+ scaleX: reverse ? 0 : 1, ease: "none", transformOrigin: "left center",
659
+ scrollTrigger: {
660
+ trigger: el,
661
+ start: startClass != null ? `top ${clamp(100 - startClass)}%` : "top bottom",
662
+ end: endClass != null ? `top ${clamp(100 - endClass)}%` : "bottom top",
663
+ scrub: true,
664
+ },
665
+ }
666
+ )
667
+ scrollTriggers.push(t.scrollTrigger)
668
+ return
669
+ }
670
+
671
+ if (el.classList.contains("scroll-fade-bg")) {
672
+ const t = gsap.fromTo(el,
673
+ { backgroundPosition: "0% 0%" },
674
+ {
675
+ backgroundPosition: "100% 100%", ease: "none",
676
+ scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true },
677
+ }
678
+ )
679
+ scrollTriggers.push(t.scrollTrigger)
680
+ return
681
+ }
682
+
683
+ if (el.classList.contains("scroll-horizontal")) {
684
+ const track = el.querySelector(".scroll-track")
685
+ if (!track) return
686
+ const getAmount = () => track.scrollWidth - el.clientWidth
687
+ const t = gsap.to(track, {
688
+ x: () => -getAmount(), ease: "none",
689
+ scrollTrigger: {
690
+ trigger: el,
691
+ start: "top top",
692
+ end: () => `+=${getAmount()}`,
693
+ pin: true,
694
+ scrub: 1,
695
+ anticipatePin: 1,
696
+ invalidateOnRefresh: true,
697
+ },
698
+ })
699
+ scrollTriggers.push(t.scrollTrigger)
700
+ }
701
+ }
702
+ gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
703
+
704
+ // `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
705
+ // Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
706
+ // rendered after a data fetch) get a trigger too, instead of only elements
707
+ // already in the DOM at setup time. A `.scroll` element is owned by its
708
+ // ScrollTrigger; `animateAppear` skips it so the entrance never double-fires.
709
+ const setupScroll = (el) => {
710
+ if (el.dataset?.gsapScroll) return
711
+ if (isTextElement(el)) return
712
+ if (isReduced(el)) return
713
+ el.dataset.gsapScroll = "1"
714
+ const config = findSpawn(el)
715
+ if (!config) return
716
+ const { from, typewriter: isTypewriter, typewriterSplit, play } = config
717
+
718
+ if (el.classList.contains("scroll-progress")) {
719
+ const ease = isTypewriter
720
+ ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
721
+ : getEase(el)
722
+
723
+ const to = {}
724
+ for (const [key] of Object.entries(from)) {
725
+ if (key === "opacity") to[key] = 1
726
+ else if (key === "filter") to[key] = "blur(0px)"
727
+ else if (key === "text") to[key] = el.innerHTML
728
+ else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
729
+ else to[key] = key.startsWith("scale") ? 1 : 0
730
+ }
731
+
732
+ const startClass = readClassNumber(el, "progress-start-", null)
733
+ const endClass = readClassNumber(el, "progress-end-", null)
734
+ const clamp = (n) => Math.min(100, Math.max(0, n))
735
+ // `.progress-reverse` runs the scrub in reverse (revealed -> hidden).
736
+ // GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
737
+ // the scrub maps in the opposite direction instead.
738
+ const reverse = el.classList.contains("progress-reverse")
739
+
740
+ const tl = gsap.timeline({
741
+ scrollTrigger: {
742
+ trigger: el,
743
+ start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
744
+ end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
745
+ scrub: true,
746
+ },
747
+ })
748
+ if (config.count) {
749
+ // Counter driven by scroll progress: count from `.spawn-num-N`
750
+ // to the target as the scrub advances (no opacity fade).
751
+ const { start, end, decimals } = countTargetVars(el)
752
+ const obj = { n: reverse ? end : start }
753
+ tl.fromTo(obj, { n: reverse ? end : start }, { n: reverse ? start : end, ease, onUpdate: () => { el.textContent = obj.n.toFixed(decimals) } }, 0)
754
+ } else if (typewriterSplit) {
755
+ const parts = getParts(el, getGranularity(el))
756
+ if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
757
+ } else {
758
+ tl.fromTo(el, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
759
+ }
760
+ scrollTriggers.push(tl.scrollTrigger)
761
+ return
762
+ }
763
+
764
+ if (!el.classList.contains("scroll")) return
765
+ const { delay, duration } = readTiming(el)
766
+ const ease = isTypewriter
767
+ ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
768
+ : getEase(el)
769
+
770
+ const fullText = el.innerHTML
771
+
772
+ const enter = () => {
773
+ if (el._scrollTween) el._scrollTween.kill()
774
+ el._scrollTween = isTypewriter
775
+ ? (typewriterSplit
776
+ ? playTypewriterSplit(el, delay, duration, ease)
777
+ : typewriter(el, fullText, duration, delay, ease))
778
+ : play(el, delay, duration, ease)
779
+ el._scrollTween.eventCallback("onComplete", () => fireOnComplete(el, "spawn"))
780
+ }
781
+ const reverseToStart = () => {
782
+ if (config.count) {
783
+ // A count spawn is a pure number timeline. Reversing it counts
784
+ // back down to the `.spawn-num-N` start value, so re-entering
785
+ // view counts up cleanly from scratch.
786
+ const t = el._scrollTween
787
+ if (t && t.progress() > 0 && !t.reversed()) t.reverse()
788
+ return
789
+ }
790
+ el._scrollTween?.kill()
791
+ if (isTypewriter && !typewriterSplit) {
792
+ el.innerHTML = fullText
793
+ return
794
+ }
795
+ if (typewriterSplit) {
796
+ const parts = getParts(el, getGranularity(el))
797
+ if (parts.length) gsap.to(parts, { opacity: 0, ease, duration: 0.3 })
798
+ return
799
+ }
800
+ el._scrollTween = gsap.to(el, { ...from, ease, duration: 0.3 })
801
+ }
802
+
803
+ const st = ScrollTrigger.create({
804
+ trigger: el,
805
+ start: "top bottom",
806
+ end: "bottom top",
807
+ onEnter: enter,
808
+ onEnterBack: enter,
809
+ onLeave: reverseToStart,
810
+ onLeaveBack: reverseToStart,
811
+ })
812
+ scrollTriggers.push(st)
813
+ }
814
+ gsap.utils.toArray(".scroll, .scroll-progress").forEach(setupScroll)
815
+
816
+ // SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
817
+ // tween when the element enters the viewport and reverses on exit.
818
+ spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
819
+ if (isTypewriter || text === false) return
820
+ const tSel = "." + TEXT_PREFIX + sel.slice(1)
821
+
822
+ gsap.utils.toArray(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
823
+ if (isReduced(el)) return
824
+ const { delay, duration } = readTiming(el)
825
+ const ease = getEase(el)
826
+ const enter = () => {
827
+ el._scrollTween?.kill()
828
+ el._scrollTween = playText(el, from, delay, duration, ease)
829
+ }
830
+ const reverseToStart = () => {
831
+ el._scrollTween?.kill()
832
+ const parts = getParts(el, getGranularity(el))
833
+ if (parts.length) gsap.to(parts, { ...from, ease, duration: 0.3 })
834
+ }
835
+ scrollTriggers.push(ScrollTrigger.create({
836
+ trigger: el,
837
+ start: "top bottom",
838
+ end: "top top",
839
+ onEnter: enter,
840
+ onEnterBack: enter,
841
+ onLeave: reverseToStart,
842
+ onLeaveBack: reverseToStart,
843
+ }))
844
+ })
845
+
846
+ gsap.utils.toArray(tSel + ".scroll-progress").forEach((el) => {
847
+ if (isReduced(el)) return
848
+ const ease = getEase(el)
849
+ const parts = getParts(el, getGranularity(el))
850
+ if (!parts.length) return
851
+ const to = computeTo(from)
852
+ const startClass = readClassNumber(el, "progress-start-", null)
853
+ const endClass = readClassNumber(el, "progress-end-", null)
854
+ const clamp = (n) => Math.min(100, Math.max(0, n))
855
+ const tl = gsap.timeline({
856
+ scrollTrigger: {
857
+ trigger: el,
858
+ start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
859
+ end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
860
+ scrub: true,
861
+ },
862
+ })
863
+ // `.progress-reverse` runs the split scrub in reverse; swap from/to.
864
+ const reverse = el.classList.contains("progress-reverse")
865
+ tl.fromTo(parts, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
866
+ scrollTriggers.push(tl.scrollTrigger)
867
+ })
868
+ })
869
+ ScrollTrigger.refresh()
870
+ window.addEventListener("load", ScrollTrigger.refresh)
871
+
872
+ // Entrance tweens (expand-down / spawn-down on containers) shift layout
873
+ // while they run. ScrollTriggers bound to their descendants (list rows,
874
+ // pagination, cards) that get measured mid-animation report stale,
875
+ // compressed positions, so they all fire onEnter/onLeave at the same
876
+ // scroll spot regardless of their real resting place. Debounce a refresh
877
+ // that fires shortly after the LAST entrance tween completes, re-measuring
878
+ // every trigger at its true position.
879
+ let refreshTimer = null
880
+ const scheduleRefresh = () => {
881
+ clearTimeout(refreshTimer)
882
+ refreshTimer = setTimeout(() => {
883
+ refreshTimer = null
884
+ ScrollTrigger.refresh()
885
+ }, 60)
886
+ }
887
+ // A couple of early passes too, in case no entrance tween completes on a
888
+ // scroll-only page: catch mounts that settle before anything finishes.
889
+ setTimeout(scheduleRefresh, 400)
890
+ setTimeout(scheduleRefresh, 1200)
891
+
892
+ spawnConfigs.forEach(({ sel, typewriter: isTypewriter, typewriterSplit, play }) => {
893
+ gsap.utils.toArray(sel).forEach((el) => {
894
+ if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
895
+ if (isPreserved(el)) return
896
+ if (isReduced(el)) return
897
+ const { delay, duration } = readTiming(el)
898
+ if (isTypewriter) {
899
+ const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
900
+ const elEase = easeClass ? easeClass.split("-")[1] : "none"
901
+ if (typewriterSplit) {
902
+ el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
903
+ } else {
904
+ el.typewriter?.kill()
905
+ el.typewriter = typewriter(el, el.innerHTML, duration, delay, elEase)
906
+ }
907
+ } else {
908
+ el._spawnTween = play(el, delay, duration, getEase(el))
909
+ el._spawnTween.eventCallback("onComplete", () => {
910
+ if (el.classList.contains("leave")) refreshLeaveRect(el)
911
+ if (el.classList.contains("flip")) captureFlip(el)
912
+ if (isCompatibility(el)) resumeCompatLoops(el)
913
+ scheduleRefresh()
914
+ fireOnComplete(el, "spawn")
915
+ })
916
+ }
917
+ markPreserved(el)
918
+ })
919
+ })
920
+
921
+ // SplitText static pass: `.spawn-text-<spawn>` plays the per-part tween on
922
+ // load (no scroll/appear). Derived from spawnConfigs so any new spawn
923
+ // class automatically gets a `.spawn-text-` variant.
924
+ spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
925
+ if (isTypewriter || text === false) return
926
+ const tSel = "." + TEXT_PREFIX + sel.slice(1)
927
+ gsap.utils.toArray(tSel).forEach((el) => {
928
+ if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
929
+ if (isPreserved(el)) return
930
+ if (isReduced(el)) return
931
+ const { delay, duration } = readTiming(el)
932
+ el._spawnTween = playText(el, from, delay, duration, getEase(el))
933
+ markPreserved(el)
934
+ })
935
+ })
936
+
937
+ const magnetQuery = typeof window !== "undefined" ? window.matchMedia("(hover: none)") : null
938
+ const magnetState = []
939
+ const magnetListeners = []
940
+ const magnetOnMove = (el, pull, grow, duration, elEase) => (ev) => {
941
+ const r = el.getBoundingClientRect()
942
+ const dx = ev.clientX - (r.left + r.width / 2)
943
+ const dy = ev.clientY - (r.top + r.height / 2)
944
+ magnet(el, dx * pull, dy * pull, grow, duration, elEase)
945
+ }
946
+ const magnetOnLeave = (el, duration, elEase) => () => magnet(el, 0, 0, 1, duration, elEase)
947
+
948
+ // `.magnet3d` moves exactly like `.magnet` but also tilts the element to
949
+ // face the cursor. The tilt is proportional to the cursor's position
950
+ // within the element (`relX`/`relY` in -0.5..0.5), scaled by the
951
+ // `mtilt-` degrees class (default 12). Cursor right/left swings it around
952
+ // the vertical axis (rotationY), cursor up/down around the horizontal
953
+ // (rotationX), so the face tracks the pointer.
954
+ const magnet3dOnMove = (el, pull, grow, tilt, duration, elEase) => (ev) => {
955
+ const r = el.getBoundingClientRect()
956
+ const dx = ev.clientX - (r.left + r.width / 2)
957
+ const dy = ev.clientY - (r.top + r.height / 2)
958
+ const relX = r.width ? (ev.clientX - r.left) / r.width - 0.5 : 0
959
+ const relY = r.height ? (ev.clientY - r.top) / r.height - 0.5 : 0
960
+ magnet3d(el, dx * pull, dy * pull, grow, -relY * tilt, relX * tilt, duration, elEase)
961
+ }
962
+ const magnet3dOnLeave = (el, duration, elEase) => () => magnet3d(el, 0, 0, 1, 0, 0, duration, elEase)
963
+
964
+ const applyMagnet = () => {
965
+ if (!magnetQuery) return
966
+ const touch = magnetQuery.matches
967
+
968
+ magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
969
+ magnetListeners.length = 0
970
+
971
+ if (touch) return
972
+
973
+ magnetState.forEach(({ el, onMove, onLeave }) => {
974
+ el.addEventListener("mousemove", onMove)
975
+ el.addEventListener("mouseleave", onLeave)
976
+ magnetListeners.push({ el, type: "mousemove", fn: onMove }, { el, type: "mouseleave", fn: onLeave })
977
+ })
978
+ }
979
+
980
+ const setupMagnet = (el) => {
981
+ if (!el.classList.contains("magnet") && !el.classList.contains("magnet3d")) return
982
+ const threeD = el.classList.contains("magnet3d")
983
+ const duration = readClassNumber(el, "mtime-", 0.4)
984
+ const pull = readClassNumber(el, "amount-", 0.3)
985
+ const grow = readClassNumber(el, "mgrow-", 1.1)
986
+ const tilt = readClassNumber(el, "mtilt-", 12)
987
+ const elEase = getEase(el)
988
+
989
+ const onMove = threeD
990
+ ? magnet3dOnMove(el, pull, grow, tilt, duration, elEase)
991
+ : magnetOnMove(el, pull, grow, duration, elEase)
992
+ const onLeave = threeD
993
+ ? magnet3dOnLeave(el, duration, elEase)
994
+ : magnetOnLeave(el, duration, elEase)
995
+
996
+ magnetState.push({ el, onMove, onLeave })
997
+ }
998
+
999
+ if (magnetQuery) magnetQuery.addEventListener("change", applyMagnet)
1000
+
1001
+ // `.compatibility` lets an always-on loop (shake/bounce/pulse/...) coexist
1002
+ // with a hover/click interaction on the SAME element. Both write to the
1003
+ // same transform properties, so without this the two tweens fight. While
1004
+ // a hover/click is active we pause every tracked loop tween on the element
1005
+ // and resume it once the interaction ends. Loops are only tracked when the
1006
+ // `.compatibility` class is present, so nothing else changes behaviour.
1007
+ const isCompatibility = (el) => el.classList.contains("compatibility")
1008
+ const compatLoopsOf = (el) => {
1009
+ if (!el._gsapCompatLoops) el._gsapCompatLoops = []
1010
+ return el._gsapCompatLoops
1011
+ }
1012
+ const trackCompatLoop = (el, tween) => {
1013
+ if (tween && isCompatibility(el)) compatLoopsOf(el).push(tween)
1014
+ return tween
1015
+ }
1016
+ const pauseCompatLoops = (el) => compatLoopsOf(el).forEach((t) => t.pause())
1017
+ const resumeCompatLoops = (el) => compatLoopsOf(el).forEach((t) => t.resume())
1018
+
1019
+ const setupClicks = (el) => {
1020
+ if (isReduced(el)) return
1021
+ setupMagnet(el)
1022
+ if (el.classList.contains("click-hover")) {
1023
+ const area = wrapTarget(el)
1024
+ let touch = false
1025
+ const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
1026
+ const lift = readClassNumber(el, "amount-", defaults.clickOffset)
1027
+ const elEase = getEase(el)
1028
+
1029
+ addListener(area, "mousedown", () => { if (!touch) { pauseCompatLoops(el); verticalmove(el, -lift / 2, duration, elEase) } })
1030
+ addListener(area, "mouseover", () => { if (!touch) { pauseCompatLoops(el); verticalmove(el, -lift, duration, elEase) } })
1031
+ addListener(area, "mouseleave", () => { if (!touch) { verticalmove(el, 0, duration, elEase); resumeCompatLoops(el) } })
1032
+ addListener(area, "mouseup", () => { if (!touch) verticalmove(el, -lift, duration, elEase) })
1033
+
1034
+ addListener(area, "touchstart", () => { touch = true, pauseCompatLoops(el), verticalmove(el, lift / 2, duration, elEase) })
1035
+ addListener(area, "touchend", () => {
1036
+ touch = true, verticalmove(el, 0, duration, elEase), setTimeout(() => { touch = false }, 0)
1037
+ })
1038
+ }
1039
+ if (el.classList.contains("click-expand")) {
1040
+ let touch = false
1041
+ const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
1042
+ const lift = readClassNumber(el, "amount-", defaults.clickExpandOffset)
1043
+ const elEase = getEase(el)
1044
+
1045
+ addListener(el, "mousedown", () => { if (!touch) { pauseCompatLoops(el); expandmove(el, 10, duration, elEase).eventCallback("onComplete", () => fireOnComplete(el, "click")) } })
1046
+ addListener(el, "mouseover", () => { if (!touch) { pauseCompatLoops(el); expandmove(el, lift, duration, elEase) } })
1047
+ addListener(el, "mouseleave", () => { if (!touch) { expandmove(el, 10, duration, elEase); resumeCompatLoops(el) } })
1048
+ addListener(el, "mouseup", () => { if (!touch) expandmove(el, lift, duration, elEase) })
1049
+
1050
+ addListener(el, "touchstart", () => { touch = true, pauseCompatLoops(el), expandmove(el, lift, duration, elEase) })
1051
+ addListener(el, "touchend", () => {
1052
+ touch = true, expandmove(el, 10, duration, elEase).eventCallback("onComplete", () => fireOnComplete(el, "click")), setTimeout(() => { touch = false }, 0)
1053
+ })
1054
+ }
1055
+ }
1056
+
1057
+ // `loopConfigs` is derived from the config in Config.js (see top of file).
1058
+ // A shared ctx object bundles the per-element timing/ease classes so each
1059
+ // entry's `build(el, ctx)` stays simple and declarative.
1060
+ const readLoopCtx = (el) => ({
1061
+ edelay: readClassNumber(el, "edelay-", defaults.effectDelay),
1062
+ amount: readClassNumber(el, "amount-", defaults.effectOffset),
1063
+ etime: readClassNumber(el, "etime-", defaults.effectDuration),
1064
+ ease: getEase(el),
1065
+ time: readClassNumber(el, "time-", 20),
1066
+ mH: readClassNumber(el, "marquee-horizontal-offset-", 0),
1067
+ mV: readClassNumber(el, "marquee-vertical-offset-", 0),
1068
+ radiateZ: readClassNumber(el, "radiate-z-", null),
1069
+ })
1070
+
1071
+ const loopEls = []
1072
+ const buildLoops = (el) => {
1073
+ if (isReduced(el)) return
1074
+ const ctx = readLoopCtx(el)
1075
+ loopConfigs.forEach(({ sel, build, key, loop }) => {
1076
+ if (el.matches(sel)) {
1077
+ el[key]?.kill()
1078
+ el[key] = trackCompatLoop(el, build(el, ctx))
1079
+ if (loop) el[key].repeat(-1)
1080
+ // Infinite loops never truly complete, so fire on each cycle
1081
+ // (onRepeat); finite ones fire on their real completion.
1082
+ el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
1083
+ () => fireOnComplete(el, "loop"))
1084
+ }
1085
+ })
1086
+ }
1087
+ // Find an in-progress spawn tween on the element or any ancestor. For a
1088
+ // `.compatibility` element, loop building is deferred until that spawn
1089
+ // settles so clone-based loops (radiate) capture the rect at the element's
1090
+ // FINAL position instead of its mid-spawn transform offset.
1091
+ const findSpawnTween = (el) => {
1092
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
1093
+ const t = node._spawnTween
1094
+ if (t && t.progress() < 1) return t
1095
+ }
1096
+ return null
1097
+ }
1098
+ const deferLoopBuild = (el, tween) => {
1099
+ if (!tween.__gsapPendingLoops) tween.__gsapPendingLoops = new Set()
1100
+ tween.__gsapPendingLoops.add(el)
1101
+ if (tween.__gsapPendingHooked) return
1102
+ tween.__gsapPendingHooked = true
1103
+ const existing = tween.eventCallback("onComplete")
1104
+ tween.eventCallback("onComplete", function () {
1105
+ existing && existing.call(this)
1106
+ const pending = tween.__gsapPendingLoops
1107
+ tween.__gsapPendingLoops = new Set()
1108
+ tween.__gsapPendingHooked = false
1109
+ pending.forEach((e) => buildLoops(e))
1110
+ })
1111
+ }
1112
+ const setupLoops = (el) => {
1113
+ const spawnTween = isCompatibility(el) ? findSpawnTween(el) : null
1114
+ if (spawnTween) {
1115
+ deferLoopBuild(el, spawnTween)
1116
+ return
1117
+ }
1118
+ buildLoops(el)
1119
+ }
1120
+
1121
+ // `hover-<name>` and `click-<name>` trigger one of the loop animations on
1122
+ // mouseenter/mousedown. The element is wrapped in a parent div that acts as
1123
+ // the stable hover/click hit area, while the element itself animates — so
1124
+ // the WHOLE box moves/scales instead of just its text, and the area never
1125
+ // shifts under the cursor. Marquee is skipped (its build restructures the
1126
+ // DOM).
1127
+ const wrapTarget = (el) => {
1128
+ if (!el.classList.contains("wrapdiv")) return el
1129
+ if (el._gsapWrap) return el._gsapWrap
1130
+ const area = document.createElement("div")
1131
+ el.before(area)
1132
+ area.appendChild(el)
1133
+ el._gsapWrap = area
1134
+ return area
1135
+ }
1136
+
1137
+ const setupHoverClick = (el) => {
1138
+ if (isReduced(el)) return
1139
+ const ctx = readLoopCtx(el)
1140
+ loopConfigs.forEach(({ sel, build, key }) => {
1141
+ if (sel.startsWith(".marquee")) return
1142
+ const name = sel.slice(1)
1143
+ if (el.classList.contains("hover-" + name)) {
1144
+ const area = wrapTarget(el)
1145
+ addListener(area, "mouseenter", () => {
1146
+ pauseCompatLoops(el)
1147
+ el[key]?.kill()
1148
+ el[key] = build(el, ctx).repeat(-1)
1149
+ el[key].eventCallback("onRepeat", () => fireOnComplete(el, "loop"))
1150
+ })
1151
+ addListener(area, "mouseleave", () => {
1152
+ el[key]?.kill()
1153
+ el[key] = reset(el, readClassNumber(el, "etime-", defaults.effectDuration), getEase(el))
1154
+ resumeCompatLoops(el)
1155
+ })
1156
+ } else if (el.classList.contains("click-" + name)) {
1157
+ const area = wrapTarget(el)
1158
+ addListener(area, "mousedown", () => {
1159
+ pauseCompatLoops(el)
1160
+ el[key]?.kill()
1161
+ el[key] = build(el, ctx)
1162
+ el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
1163
+ })
1164
+ addListener(area, "mouseleave", () => {
1165
+ resumeCompatLoops(el)
1166
+ })
1167
+ }
1168
+ })
1169
+ }
1170
+
1171
+ // Dynamic arbitrary-property animation. Class shape:
1172
+ // css-<prop>-<from>-<to> -> ping-pong loop (yoyo)
1173
+ // spawn-css-<prop>-<from>-<to> -> play once on load/appear
1174
+ // hover-css-<prop>-<from>-<to> -> ping-pong while hovered
1175
+ // click-css-<prop>-<from>-<to> -> play once on mousedown
1176
+ // hover-css-<prop>-<to> -> simple hold while hovered (no from),
1177
+ // reverts to the original value on leave
1178
+ // click-css-<prop>-<to> -> simple one-shot to the value on mousedown
1179
+ // Values are numbers (decimals/negatives ok). hover/click wrap in a div
1180
+ // so the hit area stays fixed; `from` should equal the resting value.
1181
+ const CSS_VAL = "(-?\\d+(?:\\.\\d+)?|#[0-9a-fA-F]{3,8})"
1182
+ const CSS_ANIM_RE = new RegExp(`^((spawn|hover|click)-)?css-([a-zA-Z]+)-${CSS_VAL}-${CSS_VAL}$`)
1183
+ const CSS_SINGLE_RE = new RegExp(`^((hover|click)-)css-([a-zA-Z]+)-${CSS_VAL}$`)
1184
+ const parseCssVal = (s) => /^#/.test(s) ? s : Number(s)
1185
+ const parseCssAnim = (el) => {
1186
+ for (const c of el.classList) {
1187
+ let m = c.match(CSS_ANIM_RE)
1188
+ if (m) return { mode: m[2] || "loop", prop: m[3], from: parseCssVal(m[4]), to: parseCssVal(m[5]) }
1189
+ m = c.match(CSS_SINGLE_RE)
1190
+ if (m) return { mode: m[2], prop: m[3], to: parseCssVal(m[4]), single: true }
1191
+ }
1192
+ return null
1193
+ }
1194
+ const cssTweens = []
1195
+ const setupCssAnims = (el) => {
1196
+ if (isReduced(el)) return
1197
+ const anim = parseCssAnim(el)
1198
+ if (!anim) return
1199
+ const dur = readClassNumber(el, "time-", 1)
1200
+ const ease = getEase(el)
1201
+ const key = "_cssAnim"
1202
+ const loopVars = { [anim.prop]: anim.to, duration: dur, ease, yoyo: true, repeat: -1 }
1203
+ if (anim.mode === "loop") {
1204
+ el[key]?.kill()
1205
+ el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, loopVars)
1206
+ cssTweens.push(el[key])
1207
+ } else if (anim.mode === "spawn") {
1208
+ el[key]?.kill()
1209
+ el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { [anim.prop]: anim.to, duration: dur, ease })
1210
+ cssTweens.push(el[key])
1211
+ } else if (anim.mode === "hover") {
1212
+ const area = wrapTarget(el)
1213
+ if (anim.single) {
1214
+ // Single-value hover: tween to the target and HOLD for as long
1215
+ // as it's hovered; on leave, revert to the element's original
1216
+ // value (captured at setup, before any animation touched it).
1217
+ const original = gsap.getProperty(el, anim.prop)
1218
+ addListener(area, "mouseenter", () => {
1219
+ el[key]?.kill()
1220
+ el[key] = gsap.to(el, { [anim.prop]: anim.to, duration: dur, ease })
1221
+ cssTweens.push(el[key])
1222
+ })
1223
+ addListener(area, "mouseleave", () => {
1224
+ el[key]?.kill()
1225
+ el[key] = gsap.to(el, { [anim.prop]: original, duration: dur, ease })
1226
+ cssTweens.push(el[key])
1227
+ })
1228
+ } else {
1229
+ addListener(area, "mouseenter", () => {
1230
+ el[key]?.kill()
1231
+ el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { ...loopVars })
1232
+ cssTweens.push(el[key])
1233
+ })
1234
+ addListener(area, "mouseleave", () => {
1235
+ el[key]?.kill()
1236
+ el[key] = gsap.to(el, { [anim.prop]: anim.from, duration: dur, ease })
1237
+ cssTweens.push(el[key])
1238
+ })
1239
+ }
1240
+ } else if (anim.mode === "click") {
1241
+ const area = wrapTarget(el)
1242
+ if (anim.single) {
1243
+ // Single-value click: tween to the target while pressed, then
1244
+ // revert to the element's original value on mouseup.
1245
+ const original = gsap.getProperty(el, anim.prop)
1246
+ addListener(area, "mousedown", () => {
1247
+ el[key]?.kill()
1248
+ el[key] = gsap.to(el, { [anim.prop]: anim.to, duration: dur, ease })
1249
+ el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
1250
+ cssTweens.push(el[key])
1251
+ })
1252
+ addListener(area, "mouseup", () => {
1253
+ el[key]?.kill()
1254
+ el[key] = gsap.to(el, { [anim.prop]: original, duration: dur, ease })
1255
+ cssTweens.push(el[key])
1256
+ })
1257
+ } else {
1258
+ addListener(area, "mousedown", () => {
1259
+ el[key]?.kill()
1260
+ el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { [anim.prop]: anim.to, duration: dur, ease, yoyo: true, repeat: 1 })
1261
+ el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
1262
+ cssTweens.push(el[key])
1263
+ })
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+ // Per-entry `setup` hook: a "special abilities" extension point. Any
1269
+ // config entry with a `setup(el, ctx)` function runs it once for every
1270
+ // matching element at wiring time — for behaviour that doesn't fit the
1271
+ // scroll/order/loop machinery. If it RETURNS a function, that's treated
1272
+ // as a teardown and invoked when the whole engine is torn down, so
1273
+ // side-effects (listeners, observers, timers) can be cleaned up.
1274
+ const setupTeardowns = []
1275
+ const runSetup = (el) => {
1276
+ if (isReduced(el)) return
1277
+ const ctx = readLoopCtx(el)
1278
+ animAll.forEach((a) => {
1279
+ if (a.setup && el.matches?.(a.sel)) {
1280
+ const teardown = a.setup(el, ctx)
1281
+ if (typeof teardown === "function") setupTeardowns.push(teardown)
1282
+ }
1283
+ })
1284
+ }
1285
+
1286
+ // Bind click + loop animations to every element present at load, tagging
1287
+ // them so the MutationObserver below never double-binds a dynamic one.
1288
+ gsap.utils.toArray("body *").forEach((el) => {
1289
+ if (el.dataset?.gsapSetup) return
1290
+ el.dataset.gsapSetup = "1"
1291
+ setupClicks(el)
1292
+ setupLoops(el)
1293
+ setupHoverClick(el)
1294
+ setupCssAnims(el)
1295
+ runSetup(el)
1296
+ })
1297
+ applyMagnet()
1298
+
1299
+ // Layout-change morphs. Deferred to a single rAF past the mutation so
1300
+ // layout has settled and the morph reads final positions. Multiple
1301
+ // mutations from one commit coalesce; the re-entrancy `flipping` set
1302
+ // stops the observer<->morph feedback loop.
1303
+ let flipRoots = new Set()
1304
+ let flipPendingRaf = null
1305
+ const flipObserver = new MutationObserver((mutations) => {
1306
+ for (const mutation of mutations) {
1307
+ if (mutation.type !== "childList") continue
1308
+ const target = mutation.target
1309
+ if (target.nodeType !== 1) continue
1310
+ flipRoots.add(target)
1311
+ }
1312
+ if (!flipPendingRaf) {
1313
+ flipPendingRaf = requestAnimationFrame(() => {
1314
+ flipPendingRaf = null
1315
+ if (!flipRoots.size) return
1316
+ // A single frame can register several scopes for the SAME
1317
+ // element: removing a `.leave` node re-attaches a fixed ghost
1318
+ // to <body>, which adds `body` as a second scope alongside the
1319
+ // node's former parent. Running animateFlip per scope re-enters
1320
+ // playFlip on the same element, killing the in-flight tween and
1321
+ // clearing its transform — snapping the element into place.
1322
+ // Dedupe across scopes so each element flips exactly once.
1323
+ const toFlip = new Set()
1324
+ flipRoots.forEach((scope) => {
1325
+ if (!scope) return
1326
+ gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
1327
+ .forEach((el) => { if (el.isConnected) toFlip.add(el) })
1328
+ })
1329
+ toFlip.forEach(playFlip)
1330
+ // Refresh baselines for any .flip that settled this frame.
1331
+ gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
1332
+ flipRoots = new Set()
1333
+ })
1334
+ }
1335
+ })
1336
+ flipObserver.observe(document.body, { childList: true, subtree: true })
1337
+
1338
+
1339
+
1340
+ const animateAppear = (el) => {
1341
+ if (!el.classList.contains("appear") || el._appeared) return
1342
+ // A `.scroll`/`.scroll-progress` element is owned by its ScrollTrigger
1343
+ // (see setupScroll); `.appear` must not also fire, or it plays on mount
1344
+ // AND again on scroll-enter. Text elements are the exception: their
1345
+ // `.scroll` triggers are wired once at init, so a re-added (reset) text
1346
+ // element has no trigger to conflict with and must animate via `.appear`.
1347
+ if (!isTextElement(el) && (el.classList.contains("scroll") || el.classList.contains("scroll-progress"))) return
1348
+ if (isReduced(el)) return
1349
+ el._appeared = true
1350
+ const { delay, duration, ease } = readTiming(el)
1351
+
1352
+ const config = findSpawn(el)
1353
+ if (isTextElement(el)) {
1354
+ el._spawnTween = playText(el, config.from, delay, duration, ease)
1355
+ return
1356
+ }
1357
+ if (!config) return el._spawnTween = SpawnV(el, delay, -defaults.spawnOffset, duration, ease)
1358
+
1359
+ if (config.typewriter) {
1360
+ const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
1361
+ const elEase = easeClass ? easeClass.split("-")[1] : "none"
1362
+ el._spawnTween = config.typewriterSplit
1363
+ ? playTypewriterSplit(el, delay, duration, elEase)
1364
+ : config.play(el, delay, duration, elEase)
1365
+ } else {
1366
+ el._spawnTween = config.play(el, delay, duration, ease)
1367
+ el._spawnTween.eventCallback("onComplete", () => {
1368
+ if (el.classList.contains("leave")) refreshLeaveRect(el)
1369
+ if (el.classList.contains("flip")) captureFlip(el)
1370
+ fireOnComplete(el, "spawn")
1371
+ })
1372
+ }
1373
+ }
1374
+
1375
+ const appearObserver = new MutationObserver((mutations) => {
1376
+ mutations.forEach((mutation) => {
1377
+ mutation.addedNodes.forEach((node) => {
1378
+ if (node.nodeType !== 1) return
1379
+ const els = node.querySelectorAll?.("*") ? [node, ...node.querySelectorAll("*")] : [node]
1380
+ let pinned = false
1381
+ els.forEach((el) => {
1382
+ // `.appear` is the opt-in gate for dynamically-added
1383
+ // elements: without it a newly inserted node is ignored.
1384
+ if (!el.classList?.contains("appear")) return
1385
+ animateAppear(el)
1386
+ setupScroll(el)
1387
+ setupScrollDriven(el)
1388
+ if (el.dataset?.gsapSetup) return
1389
+ el.dataset.gsapSetup = "1"
1390
+ setupClicks(el)
1391
+ setupLoops(el)
1392
+ setupHoverClick(el)
1393
+ setupCssAnims(el)
1394
+ runSetup(el)
1395
+ const wasPinned = el.dataset.gsapPinned
1396
+ setupPin(el)
1397
+ if (!wasPinned && el.dataset.gsapPinned) pinned = true
1398
+ })
1399
+ applyMagnet()
1400
+ // A newly added pin changes layout; refresh so its spacer is
1401
+ // accounted for before the next scroll calc.
1402
+ if (pinned) ScrollTrigger.refresh()
1403
+ })
1404
+ })
1405
+ })
1406
+ appearObserver.observe(document.body, { childList: true, subtree: true })
1407
+
1408
+ // Capture any .leave elements already present so they can exit later
1409
+ gsap.utils.toArray(".leave").forEach(captureLeave)
1410
+
1411
+ const leaveObserver = new MutationObserver((mutations) => {
1412
+ mutations.forEach((mutation) => {
1413
+ if (mutation.type !== "childList") return
1414
+ mutation.addedNodes.forEach((n) => collectLeave(n).forEach(captureLeave))
1415
+ mutation.removedNodes.forEach((n) => collectLeave(n).forEach(playLeave))
1416
+ })
1417
+ })
1418
+ leaveObserver.observe(document.body, { childList: true, subtree: true })
1419
+
1420
+ // Keep the captured position fresh (throttled to one pass per frame)
1421
+ let positionTick = false
1422
+ const refreshLeavePositions = () => {
1423
+ if (positionTick) return
1424
+ positionTick = true
1425
+ requestAnimationFrame(() => {
1426
+ gsap.utils.toArray(".leave").forEach((el) => {
1427
+ const s = leaveStates.get(el)
1428
+ if (s) s.rect = el.getBoundingClientRect()
1429
+ })
1430
+ positionTick = false
1431
+ })
1432
+ }
1433
+ window.addEventListener("scroll", refreshLeavePositions, { passive: true })
1434
+ window.addEventListener("resize", refreshLeavePositions, { passive: true })
1435
+
1436
+ return () => {
1437
+ appearObserver.disconnect()
1438
+ leaveObserver.disconnect()
1439
+ flipObserver.disconnect()
1440
+ window.removeEventListener("scroll", refreshLeavePositions)
1441
+ window.removeEventListener("resize", refreshLeavePositions)
1442
+ window.removeEventListener("load", ScrollTrigger.refresh)
1443
+ clearTimeout(refreshTimer)
1444
+ scrollTriggers.forEach((t) => {
1445
+ t.kill()
1446
+ t.trigger._scrollTween?.kill()
1447
+ delete t.trigger._scrollTween
1448
+ })
1449
+ ScrollTrigger.refresh()
1450
+ registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1451
+ magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1452
+ magnetQuery?.removeEventListener("change", applyMagnet)
1453
+ loopEls.forEach(({ el, key }) => el[key]?.kill())
1454
+ cssTweens.forEach((t) => t?.kill())
1455
+ cssTweens.length = 0
1456
+ gsap.utils.toArray(".typewriter").forEach(el => el.typewriter?.kill())
1457
+ textSplits.forEach((s) => s.revert())
1458
+ textSplits.length = 0
1459
+ onCompleteTweens.forEach((t) => t?.kill())
1460
+ onCompleteTweens.length = 0
1461
+ setupTeardowns.forEach((fn) => { try { fn() } catch { /* ignore */ } })
1462
+ setupTeardowns.length = 0
1463
+ }
1464
+ }