gclass-anims 1.0.0-beta.2 → 1.0.0-beta.21

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 CHANGED
@@ -1,7 +1,7 @@
1
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'
2
+ import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars, stashText, scrambleVars } from './Animations.js'
3
+ import { customAnims } from './CustomAnims.js'
4
+ import { defaults, normalize } from './Config.js'
5
5
  import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
6
6
 
7
7
  // Prefix for SplitText text-reveal classes. Distinct from the raw `text-*`
@@ -30,9 +30,90 @@ export function resolveHandler(name) {
30
30
  return null
31
31
  }
32
32
 
33
- export default function initListeners() {
33
+ // --- .randomize-<prop>-[min]-[max] randomization -----------------------------
34
+ // Adds function-based values (e.g. rotation: () => gsap.utils.random(-90, 90))
35
+ // to the FROM state of spawn tweens, so each element enters from its own pose.
36
+ // GSAP evaluates function values on tween BUILD, and the engine always kills +
37
+ // rebuilds tweens on replay (.scroll re-enter, .appear re-insert), so every
38
+ // replay re-rolls automatically - no invalidate/repeatRefresh bookkeeping.
39
+ //
40
+ // The class is the guard: hasRandom() is a cheap className probe and nothing
41
+ // below allocates or patches anything unless it passes, so elements without
42
+ // .randomize-* run exactly the pre-feature code path.
43
+ //
44
+ // Notes:
45
+ // • randomize OVERRIDES the base spawn's value for that prop (a
46
+ // randomize-rotation on .spawn-cw replaces the spin).
47
+ // • A matching END value is derived per prop (scale* -> 1, opacity -> 1,
48
+ // transforms -> 0, ...) unless the config's own `to` already animates the
49
+ // prop, so a randomized prop on a spawn that doesn't natively use it still
50
+ // tweens back to rest instead of sticking at the rolled value.
51
+ // • Timeline-mediated builders (count/scramble/draw-split) construct via
52
+ // Timeline methods, not the exported gsap.fromTo, so they sit outside the
53
+ // injection - irrelevant in practice, since their props aren't randomize
54
+ // targets.
55
+ const RANDOMIZE_RE = /^randomize-(\w+)-\[(-?[\d.]+)\]-\[(-?[\d.]+)\]$/
56
+ const hasRandom = (el) => typeof el.className === "string" && /\brandomize-/.test(el.className)
57
+ const randomVars = (el) => {
58
+ const rnd = {}
59
+ for (const c of el.classList) {
60
+ const m = c.match(RANDOMIZE_RE)
61
+ if (m) rnd[m[1]] = () => gsap.utils.random(Number(m[2]), Number(m[3]))
62
+ }
63
+ return rnd
64
+ }
65
+ // Resting end-state for a randomized prop (mirrors computeTo's rules).
66
+ const randomEnds = (keys) => Object.fromEntries(keys.map((k) => [
67
+ k,
68
+ k === "opacity" ? 1
69
+ : k === "filter" ? "blur(0px)"
70
+ : k === "clipPath" ? "inset(0% 0% 0% 0%)"
71
+ : k === "drawSVG" ? "100%"
72
+ : k.startsWith("scale") ? 1
73
+ : 0,
74
+ ]))
75
+ // Runs a spawn config's play(), injecting the element's .randomize-* function
76
+ // values into every from-state the config builds. Config helpers hardcode
77
+ // their from literals inside Animations.js, so the injection hooks the only
78
+ // interception point available: play() builds its tweens SYNCHRONOUSLY, which
79
+ // makes a scoped gsap.fromTo swap safe - patch, let the config construct,
80
+ // restore in finally{}. Without .randomize-* this is a bare passthrough.
81
+ const invokePlay = (config, el, delay, dur, ease) => {
82
+ if (!hasRandom(el)) return config.play(el, delay, dur, ease)
83
+ const rnd = randomVars(el)
84
+ const keys = Object.keys(rnd)
85
+ if (!keys.length) return config.play(el, delay, dur, ease)
86
+ const ends = randomEnds(keys)
87
+ const origFromTo = gsap.fromTo
88
+ gsap.fromTo = (t, from, to) => origFromTo(t, { ...from, ...rnd }, { ...ends, ...to })
89
+ try {
90
+ return config.play(el, delay, dur, ease)
91
+ } finally {
92
+ gsap.fromTo = origFromTo
93
+ }
94
+ }
95
+
96
+ export default function initListeners(root = document, throttlePerFrame) {
97
+ // overload: initListeners(1) -> throttle only, root defaults to document
98
+ if (typeof root === 'number') {
99
+ throttlePerFrame = root
100
+ root = document
101
+ }
102
+ throttlePerFrame = Number(throttlePerFrame) || 0 // 0 = no throttling (default)
34
103
  gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
35
104
 
105
+ // helper to scope queries to root (for boot screen: only boot-up subtree animates during boot)
106
+ const qAll = (sel) => {
107
+ if (root === document || root === document.documentElement || root === document.body) return gsap.utils.toArray(sel)
108
+ if (sel === "body *") return gsap.utils.toArray(root.querySelectorAll("*"))
109
+ try {
110
+ const els = [...(root.querySelectorAll ? root.querySelectorAll(sel) : [])]
111
+ if (root.matches?.(sel)) els.unshift(root)
112
+ // handle comma selectors where root itself may match one part
113
+ return els
114
+ } catch { return gsap.utils.toArray(sel) }
115
+ }
116
+
36
117
  const registeredListeners = []
37
118
  const onCompleteTweens = []
38
119
  const addListener = (el, type, fn) => {
@@ -57,19 +138,37 @@ export default function initListeners() {
57
138
 
58
139
  // `.preserve` keeps an already-rendered element (e.g. one that persists
59
140
  // 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.
141
+ // the Listeners setup re-runs. It applies to the element AND its
142
+ // children: any preserved ancestor also suppresses animation on this
143
+ // node. Two subtleties make this behave correctly:
144
+ // markPreserved() tags the FULL preserve-ancestor chain, so a bare
145
+ // `.preserve` container without its own spawn class (a site header,
146
+ // say) still gets tagged when one of its children animates.
147
+ // • Suppression requires the element ITSELF to carry data-gsap-wired
148
+ // (set when a previous run animated it). Freshly mounted content
149
+ // under a preserved root therefore still plays its entrance - only
150
+ // DOM that survived from an earlier run stays frozen.
66
151
  const isPreserved = (el) => {
152
+ if (!el.dataset.gsapWired) return false
153
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
154
+ if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
155
+ }
156
+ return false
157
+ }
158
+ const markPreserved = (el) => {
159
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
160
+ if (node.classList.contains("preserve")) node.dataset.gsapPreserved = "1"
161
+ }
162
+ }
163
+ // True when el sits inside a `.preserve` root that a run has already
164
+ // tagged. Such regions are frozen by design: later runs skip them, so
165
+ // teardown must leave their visual state untouched too.
166
+ const underPreservedRoot = (el) => {
67
167
  for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
68
168
  if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
69
169
  }
70
170
  return false
71
171
  }
72
- const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
73
172
 
74
173
  // `spawnConfigs` is derived from the config in Config.js (see top of
75
174
  // file). Adding/removing an entry there automatically re-wires every
@@ -126,7 +225,7 @@ export default function initListeners() {
126
225
  }
127
226
 
128
227
  // Capture the element's first (resting) bounds. A later layout change
129
- // morphs from this snapshot to the live position a vanilla FLIP.
228
+ // morphs from this snapshot to the live position - a vanilla FLIP.
130
229
  const captureFlip = (node) => {
131
230
  if (!node.classList?.contains("flip")) return
132
231
  const config = findSpawn(node)
@@ -277,7 +376,7 @@ export default function initListeners() {
277
376
  spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
278
377
 
279
378
  const getOrderDelay = (el, priority) => {
280
- const samepri = gsap.utils.toArray(orderSelector())
379
+ const samepri = qAll(orderSelector())
281
380
  .filter((e) => {
282
381
  if (!e.classList.contains("order")) return false
283
382
  const match = [...e.classList].find(p => p.startsWith("priority-"))
@@ -317,7 +416,7 @@ export default function initListeners() {
317
416
  const delay = readClassNumber(el, "complete-delay-", 0)
318
417
  const dur = readClassNumber(el, "complete-time-", 1)
319
418
  const tween = entry.play
320
- ? entry.play(el, delay, dur, getEase(el))
419
+ ? invokePlay(entry, el, delay, dur, getEase(el))
321
420
  : entry.build(el, readLoopCtx(el))
322
421
  if (!tween) return
323
422
  if (!entry.play) tween.delay(delay)
@@ -337,12 +436,16 @@ export default function initListeners() {
337
436
  }
338
437
 
339
438
  const scrollTriggers = []
439
+ // Derives the fully-visible twin of a spawn's `from` state. drawSVG is
440
+ // inverted relative to the numeric default: its HIDDEN value is 0%, so
441
+ // the drawn end state is the full stroke.
340
442
  const computeTo = (from) => {
341
443
  const to = {}
342
- for (const [key] of Object.entries(from)) {
444
+ for (const [key] of Object.entries(from || {})) {
343
445
  if (key === "opacity") to[key] = 1
344
446
  else if (key === "filter") to[key] = "blur(0px)"
345
447
  else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
448
+ else if (key === "drawSVG") to[key] = "100%"
346
449
  else to[key] = key.startsWith("scale") ? 1 : 0
347
450
  }
348
451
  return to
@@ -381,8 +484,8 @@ export default function initListeners() {
381
484
  // whitespace) must not become tween targets; they are kept as inert text
382
485
  // nodes so the natural gap is preserved and never animated.
383
486
  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
487
+ // A real Arabic/Persian joining letter. Any other visible character -
488
+ // Latin, digits, punctuation (، ؟ ؛ . ! …) - is NOT a joining letter: it
386
489
  // must not give the preceding letter a trailing Zero-Width-Joiner (which
387
490
  // would render it in its connecting form instead of its correct END form),
388
491
  // but it should still be split into its own span so it animates too.
@@ -471,20 +574,80 @@ export default function initListeners() {
471
574
  },
472
575
  })
473
576
 
577
+ // Flex targets can't be split directly: the split parts would become
578
+ // flex ITEMS, so justify-content/gap would apply per letter, whitespace-
579
+ // only text nodes stop rendering (spaces vanish) and line grouping reads
580
+ // garbage. Loose text runs are therefore pre-wrapped in plain block
581
+ // divs - real boxes with normal inline flow inside - and the wrappers
582
+ // are undone whenever the split reverts. Non-text children (icons etc.)
583
+ // stay put, keeping their own flex-item status and the gaps around them.
584
+ const wrapFlexTarget = (el) => {
585
+ if (!/^(inline-)?flex$/.test(getComputedStyle(el).display)) return null
586
+ const wrappers = []
587
+ let run = []
588
+ const flush = () => {
589
+ if (!run.length) return
590
+ const w = document.createElement("div")
591
+ el.insertBefore(w, run[0])
592
+ run.forEach((n) => w.appendChild(n))
593
+ wrappers.push(w)
594
+ run = []
595
+ }
596
+ ;[...el.childNodes].forEach((n) => {
597
+ if (n.nodeType === 3 && n.textContent.trim()) run.push(n)
598
+ else flush()
599
+ })
600
+ flush()
601
+ if (!wrappers.length) return null
602
+ return () => wrappers.forEach((w) => {
603
+ while (w.firstChild) el.insertBefore(w.firstChild, w)
604
+ w.remove()
605
+ })
606
+ }
607
+
608
+ // Revert a SplitText instance AND undo any flex pre-wrapping made for
609
+ // it. Every revert path funnels through here so wrappers can't leak.
610
+ const revertSplitInstance = (s) => {
611
+ s.revert()
612
+ s._gcFlexUnwrap?.()
613
+ }
614
+
474
615
  const getSplit = (el, gran) => {
475
616
  let s = splitCache.get(el)
476
617
  const rtlChars = gran === "chars" && isRTLText(el)
477
618
  const key = rtlChars ? "rtl-chars" : gran
478
619
  if (!s || s.granularity !== key) {
479
- s?.revert()
620
+ if (s) revertSplitInstance(s)
621
+ const hasN = el.textContent.includes('\n')
622
+ // only set pre-wrap if element actually contains a newline, so \n renders without breaking other SplitText (flex/lines)
623
+ if (hasN && el.style) el.style.whiteSpace = 'pre-wrap'
480
624
  s = rtlChars
481
625
  ? getRTLCharSplit(el)
482
626
  : new SplitText(el, {
483
627
  type: gran,
628
+ reduceWhiteSpace: hasN ? false : undefined,
484
629
  linesClass: "gsap-line",
485
630
  wordsClass: "gsap-word",
486
631
  charsClass: "gsap-char",
632
+ onSplit(self) {
633
+ // make \n actually break line DURING split (not just after revert)
634
+ for (const c of self.chars || []) {
635
+ if (c.textContent === '\n') {
636
+ c.textContent = ''
637
+ c.style.display = 'block'
638
+ c.style.width = '100%'
639
+ c.style.height = '0'
640
+ }
641
+ }
642
+ // also handle words split where \n is between words
643
+ for (const w of self.words || []) {
644
+ if (w.textContent.includes('\n')) {
645
+ w.style.whiteSpace = 'pre-wrap'
646
+ }
647
+ }
648
+ }
487
649
  })
650
+ s._gcFlexUnwrap = wrapFlexTarget(el)
488
651
  s.granularity = key
489
652
  splitCache.set(el, s)
490
653
  textSplits.push(s)
@@ -498,8 +661,10 @@ export default function initListeners() {
498
661
  // does this automatically for animated elements; Firefox is
499
662
  // conservative and otherwise repaints these inline-block parts on the
500
663
  // main thread every frame, which is what makes split-text lag there.
664
+ // A: keep inline flow so <strong> etc stays inline during split
501
665
  for (let i = 0; i < parts.length; i++) {
502
666
  parts[i].style.willChange = "transform, opacity"
667
+ if (gran === "chars" || gran === "words") parts[i].style.display = "inline-block"
503
668
  }
504
669
  return parts
505
670
  }
@@ -514,13 +679,13 @@ export default function initListeners() {
514
679
  splitCache.delete(el)
515
680
  const idx = textSplits.indexOf(s)
516
681
  if (idx !== -1) textSplits.splice(idx, 1)
517
- s.revert()
682
+ revertSplitInstance(s)
518
683
  }
519
684
  // The whole point of `.time-X` on a `.spawn-text-X` element is that the
520
685
  // FULL reveal (first part starting to last part finishing) takes X
521
686
  // seconds, no matter how many chars/words/lines it got split into.
522
687
  // GSAP staggered tweens actually finish at `duration + stagger * (n-1)`,
523
- // so `dur` can't be handed straight to `duration` as before instead we
688
+ // so `dur` can't be handed straight to `duration` as before - instead we
524
689
  // solve for `duration`/`stagger` together so they always sum to `dur`.
525
690
  // An explicit `.stagger-N` class is honored as-is; only `duration` is
526
691
  // back-solved in that case so the last part still lands on `dur`.
@@ -545,8 +710,17 @@ export default function initListeners() {
545
710
  duration = Math.min(dur, Math.max(dur / 3, defaults.minTextPartDuration))
546
711
  stagger = parts.length > 1 ? (dur - duration) / (parts.length - 1) : 0
547
712
  }
548
- return gsap.fromTo(parts, { ...from }, {
549
- ...computeTo(from), ease, duration, delay, stagger,
713
+ // .randomize-*: per-PART roll (function values evaluate once per
714
+ // target, so every char/word/line lands on its own pose); end
715
+ // states recompute from the merged keys so new props tween to rest.
716
+ let rnd = null
717
+ if (hasRandom(el)) {
718
+ rnd = randomVars(el)
719
+ if (!Object.keys(rnd).length) rnd = null
720
+ }
721
+ const effFrom = { ...from, ...rnd }
722
+ return gsap.fromTo(parts, effFrom, {
723
+ ...computeTo(effFrom), ease, duration, delay, stagger,
550
724
  onComplete: () => {
551
725
  if (el.classList.contains("leave")) refreshLeaveRect(el)
552
726
  revertSplit(el)
@@ -606,9 +780,9 @@ export default function initListeners() {
606
780
  // created before any scroll/scroll-progress trigger measures its position.
607
781
  // Setting them up here (before the trigger pass below) keeps offsets correct
608
782
  // and lets the single ScrollTrigger.refresh() at the end reconcile layout.
609
- gsap.utils.toArray(".pin").forEach(setupPin)
783
+ qAll(".pin").forEach(setupPin)
610
784
 
611
- // Scroll-driven extras class-driven ScrollTrigger behaviours that don't
785
+ // Scroll-driven extras - class-driven ScrollTrigger behaviours that don't
612
786
  // fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
613
787
  // .parallax-N - element drifts relative to scroll. N is a
614
788
  // speed factor: 1 = static, <1 = slower,
@@ -699,7 +873,17 @@ export default function initListeners() {
699
873
  scrollTriggers.push(t.scrollTrigger)
700
874
  }
701
875
  }
702
- gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
876
+ qAll('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
877
+
878
+ // Scroller resolution: a `.scroll`/`.scroll-progress` element inside a
879
+ // `.scroll-frame` container binds its trigger to THAT box instead of the
880
+ // window. Innermost frame wins (`closest` walks up); no frame ancestor
881
+ // keeps the default window scroller. The frame must be a real scroller
882
+ // (fixed height + overflow auto/scroll) or its triggers never fire.
883
+ const getScroller = (el) => {
884
+ const frame = el.closest?.(".scroll-frame")
885
+ return frame && frame !== el ? frame : undefined
886
+ }
703
887
 
704
888
  // `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
705
889
  // Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
@@ -721,11 +905,12 @@ export default function initListeners() {
721
905
  : getEase(el)
722
906
 
723
907
  const to = {}
724
- for (const [key] of Object.entries(from)) {
908
+ for (const [key] of Object.entries(from || {})) {
725
909
  if (key === "opacity") to[key] = 1
726
910
  else if (key === "filter") to[key] = "blur(0px)"
727
- else if (key === "text") to[key] = el.innerHTML
911
+ else if (key === "text") to[key] = stashText(el)
728
912
  else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
913
+ else if (key === "drawSVG") to[key] = "100%"
729
914
  else to[key] = key.startsWith("scale") ? 1 : 0
730
915
  }
731
916
 
@@ -736,10 +921,16 @@ export default function initListeners() {
736
921
  // GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
737
922
  // the scrub maps in the opposite direction instead.
738
923
  const reverse = el.classList.contains("progress-reverse")
924
+ // .randomize-* applies to the HIDDEN start only (non-reverse):
925
+ // a scrub's resting end must stay deterministic or the element
926
+ // would sit permanently off-pose after being scrolled through.
927
+ const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
928
+ const rndEnds = rnd ? randomEnds(Object.keys(rnd)) : null
739
929
 
740
930
  const tl = gsap.timeline({
741
931
  scrollTrigger: {
742
932
  trigger: el,
933
+ scroller: getScroller(el),
743
934
  start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
744
935
  end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
745
936
  scrub: true,
@@ -751,11 +942,50 @@ export default function initListeners() {
751
942
  const { start, end, decimals } = countTargetVars(el)
752
943
  const obj = { n: reverse ? end : start }
753
944
  tl.fromTo(obj, { n: reverse ? end : start }, { n: reverse ? start : end, ease, onUpdate: () => { el.textContent = obj.n.toFixed(decimals) } }, 0)
945
+ } else if (config.scramble) {
946
+ // Scramble driven by scroll progress: each top-level text
947
+ // run scrubs through garbage states (.progress-reverse
948
+ // swaps the endpoints). The generic from/to scrub can't
949
+ // express this (it only knows the numeric `from` keys),
950
+ // which is why the entry carries the scramble flag.
951
+ // .scramble-all has no empty state: its tween runs between
952
+ // identical endpoints so the scrub just drives the sweep.
953
+ // Same ease rule as play(): linear unless .ease-* present.
954
+ const { segs, chars, speed, revealDelay, rtl } = scrambleVars(el)
955
+ const all = el.classList.contains("scramble-all")
956
+ segs.forEach(({ t, text }) => {
957
+ if (all) {
958
+ tl.to(t,
959
+ { scrambleText: { text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
960
+ } else {
961
+ tl.fromTo(t,
962
+ { scrambleText: { text: reverse ? text : "", chars } },
963
+ { scrambleText: { text: reverse ? "" : text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
964
+ }
965
+ })
754
966
  } else if (typewriterSplit) {
755
967
  const parts = getParts(el, getGranularity(el))
756
968
  if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
969
+ } else if (el.classList.contains("fill-svg") && (el.classList.contains("draw") || el.classList.contains("draw-split"))) {
970
+ // fill-svg modifier for draw: stroke first, then fill, sequential scrub.
971
+ // For scrub the two phases are sequential tweens so scroll maps
972
+ // draw → fill. Reverse swaps order so unfill happens before undraw.
973
+ const fillEaseCls = [...el.classList].find(c => c.startsWith("fill-ease-"))
974
+ const fillEase = fillEaseCls ? fillEaseCls.slice("fill-ease-".length) : ease
975
+ const fillTimeCls = [...el.classList].find(c => c.startsWith("fill-time-"))
976
+ const fillDur = fillTimeCls ? Number(fillTimeCls.slice("fill-time-".length)) : 0.5
977
+ const drawDur = 1
978
+ // Ensure the hidden / revealed fill state is correct before scrub starts
979
+ gsap.set(el, { fillOpacity: reverse ? 1 : 0 })
980
+ if (!reverse) {
981
+ tl.fromTo(el, { drawSVG: "0%" , ...rnd }, { drawSVG: "100%" , ...rndEnds, ease, duration: drawDur })
982
+ tl.fromTo(el, { fillOpacity: 0 }, { fillOpacity: 1, ease: fillEase, duration: fillDur })
983
+ } else {
984
+ tl.fromTo(el, { fillOpacity: 1 }, { fillOpacity: 0, ease: fillEase, duration: fillDur })
985
+ tl.fromTo(el, { drawSVG: "100%" , ...rndEnds }, { drawSVG: "0%" , ...rnd, ease, duration: drawDur })
986
+ }
757
987
  } else {
758
- tl.fromTo(el, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
988
+ tl.fromTo(el, { ...(reverse ? to : from), ...rnd }, { ...rndEnds, ...(reverse ? from : to), ease })
759
989
  }
760
990
  scrollTriggers.push(tl.scrollTrigger)
761
991
  return
@@ -767,7 +997,7 @@ export default function initListeners() {
767
997
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
768
998
  : getEase(el)
769
999
 
770
- const fullText = el.innerHTML
1000
+ const fullText = stashText(el)
771
1001
 
772
1002
  const enter = () => {
773
1003
  if (el._scrollTween) el._scrollTween.kill()
@@ -775,7 +1005,7 @@ export default function initListeners() {
775
1005
  ? (typewriterSplit
776
1006
  ? playTypewriterSplit(el, delay, duration, ease)
777
1007
  : typewriter(el, fullText, duration, delay, ease))
778
- : play(el, delay, duration, ease)
1008
+ : invokePlay(config, el, delay, duration, ease)
779
1009
  el._scrollTween.eventCallback("onComplete", () => fireOnComplete(el, "spawn"))
780
1010
  }
781
1011
  const reverseToStart = () => {
@@ -802,6 +1032,7 @@ export default function initListeners() {
802
1032
 
803
1033
  const st = ScrollTrigger.create({
804
1034
  trigger: el,
1035
+ scroller: getScroller(el),
805
1036
  start: "top bottom",
806
1037
  end: "bottom top",
807
1038
  onEnter: enter,
@@ -811,7 +1042,7 @@ export default function initListeners() {
811
1042
  })
812
1043
  scrollTriggers.push(st)
813
1044
  }
814
- gsap.utils.toArray(".scroll, .scroll-progress").forEach(setupScroll)
1045
+ qAll(".scroll, .scroll-progress").forEach(setupScroll)
815
1046
 
816
1047
  // SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
817
1048
  // tween when the element enters the viewport and reverses on exit.
@@ -819,7 +1050,7 @@ export default function initListeners() {
819
1050
  if (isTypewriter || text === false) return
820
1051
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
821
1052
 
822
- gsap.utils.toArray(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
1053
+ qAll(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
823
1054
  if (isReduced(el)) return
824
1055
  const { delay, duration } = readTiming(el)
825
1056
  const ease = getEase(el)
@@ -834,6 +1065,7 @@ export default function initListeners() {
834
1065
  }
835
1066
  scrollTriggers.push(ScrollTrigger.create({
836
1067
  trigger: el,
1068
+ scroller: getScroller(el),
837
1069
  start: "top bottom",
838
1070
  end: "top top",
839
1071
  onEnter: enter,
@@ -843,7 +1075,7 @@ export default function initListeners() {
843
1075
  }))
844
1076
  })
845
1077
 
846
- gsap.utils.toArray(tSel + ".scroll-progress").forEach((el) => {
1078
+ qAll(tSel + ".scroll-progress").forEach((el) => {
847
1079
  if (isReduced(el)) return
848
1080
  const ease = getEase(el)
849
1081
  const parts = getParts(el, getGranularity(el))
@@ -855,14 +1087,19 @@ export default function initListeners() {
855
1087
  const tl = gsap.timeline({
856
1088
  scrollTrigger: {
857
1089
  trigger: el,
1090
+ scroller: getScroller(el),
858
1091
  start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
859
1092
  end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
860
1093
  scrub: true,
861
1094
  },
862
1095
  })
863
1096
  // `.progress-reverse` runs the split scrub in reverse; swap from/to.
1097
+ // Randomize applies to the hidden (non-reverse) start only, same
1098
+ // rule as the element-level scrub above.
864
1099
  const reverse = el.classList.contains("progress-reverse")
865
- tl.fromTo(parts, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
1100
+ const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
1101
+ tl.fromTo(parts, { ...(reverse ? to : from), ...rnd },
1102
+ { ...(rnd ? randomEnds(Object.keys(rnd)) : null), ...(reverse ? from : to), ease })
866
1103
  scrollTriggers.push(tl.scrollTrigger)
867
1104
  })
868
1105
  })
@@ -889,8 +1126,9 @@ export default function initListeners() {
889
1126
  setTimeout(scheduleRefresh, 400)
890
1127
  setTimeout(scheduleRefresh, 1200)
891
1128
 
892
- spawnConfigs.forEach(({ sel, typewriter: isTypewriter, typewriterSplit, play }) => {
893
- gsap.utils.toArray(sel).forEach((el) => {
1129
+ spawnConfigs.forEach((config) => {
1130
+ const { sel, typewriter: isTypewriter, typewriterSplit } = config
1131
+ qAll(sel).forEach((el) => {
894
1132
  if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
895
1133
  if (isPreserved(el)) return
896
1134
  if (isReduced(el)) return
@@ -902,10 +1140,10 @@ export default function initListeners() {
902
1140
  el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
903
1141
  } else {
904
1142
  el.typewriter?.kill()
905
- el.typewriter = typewriter(el, el.innerHTML, duration, delay, elEase)
1143
+ el.typewriter = typewriter(el, stashText(el), duration, delay, elEase)
906
1144
  }
907
1145
  } else {
908
- el._spawnTween = play(el, delay, duration, getEase(el))
1146
+ el._spawnTween = invokePlay(config, el, delay, duration, getEase(el))
909
1147
  el._spawnTween.eventCallback("onComplete", () => {
910
1148
  if (el.classList.contains("leave")) refreshLeaveRect(el)
911
1149
  if (el.classList.contains("flip")) captureFlip(el)
@@ -915,6 +1153,7 @@ export default function initListeners() {
915
1153
  })
916
1154
  }
917
1155
  markPreserved(el)
1156
+ el.dataset.gsapWired = "1"
918
1157
  })
919
1158
  })
920
1159
 
@@ -924,13 +1163,14 @@ export default function initListeners() {
924
1163
  spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
925
1164
  if (isTypewriter || text === false) return
926
1165
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
927
- gsap.utils.toArray(tSel).forEach((el) => {
1166
+ qAll(tSel).forEach((el) => {
928
1167
  if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
929
1168
  if (isPreserved(el)) return
930
1169
  if (isReduced(el)) return
931
1170
  const { delay, duration } = readTiming(el)
932
1171
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
933
1172
  markPreserved(el)
1173
+ el.dataset.gsapWired = "1"
934
1174
  })
935
1175
  })
936
1176
 
@@ -1077,8 +1317,11 @@ export default function initListeners() {
1077
1317
  el[key]?.kill()
1078
1318
  el[key] = trackCompatLoop(el, build(el, ctx))
1079
1319
  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.
1320
+ // Track wired loops so teardown can kill them (radiate
1321
+ // relies on kill/onInterrupt to remove its clones).
1322
+ if (!loopEls.some((l) => l.el === el && l.key === key)) {
1323
+ loopEls.push({ el, key })
1324
+ }
1082
1325
  el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
1083
1326
  () => fireOnComplete(el, "loop"))
1084
1327
  }
@@ -1120,7 +1363,7 @@ export default function initListeners() {
1120
1363
 
1121
1364
  // `hover-<name>` and `click-<name>` trigger one of the loop animations on
1122
1365
  // 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
1366
+ // the stable hover/click hit area, while the element itself animates - so
1124
1367
  // the WHOLE box moves/scales instead of just its text, and the area never
1125
1368
  // shifts under the cursor. Marquee is skipped (its build restructures the
1126
1369
  // DOM).
@@ -1267,7 +1510,7 @@ export default function initListeners() {
1267
1510
 
1268
1511
  // Per-entry `setup` hook: a "special abilities" extension point. Any
1269
1512
  // 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
1513
+ // matching element at wiring time - for behaviour that doesn't fit the
1271
1514
  // scroll/order/loop machinery. If it RETURNS a function, that's treated
1272
1515
  // as a teardown and invoked when the whole engine is torn down, so
1273
1516
  // side-effects (listeners, observers, timers) can be cleaned up.
@@ -1285,7 +1528,7 @@ export default function initListeners() {
1285
1528
 
1286
1529
  // Bind click + loop animations to every element present at load, tagging
1287
1530
  // them so the MutationObserver below never double-binds a dynamic one.
1288
- gsap.utils.toArray("body *").forEach((el) => {
1531
+ qAll("body *").forEach((el) => {
1289
1532
  if (el.dataset?.gsapSetup) return
1290
1533
  el.dataset.gsapSetup = "1"
1291
1534
  setupClicks(el)
@@ -1302,38 +1545,28 @@ export default function initListeners() {
1302
1545
  // stops the observer<->morph feedback loop.
1303
1546
  let flipRoots = new Set()
1304
1547
  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 })
1548
+ const flushFlipRoots = () => {
1549
+ if (!flipRoots.size) return
1550
+ // A single frame can register several scopes for the SAME
1551
+ // element: removing a `.leave` node re-attaches a fixed ghost
1552
+ // to <body>, which adds `body` as a second scope alongside the
1553
+ // node's former parent. Running animateFlip per scope re-enters
1554
+ // playFlip on the same element, killing the in-flight tween and
1555
+ // clearing its transform - snapping the element into place.
1556
+ // Dedupe across scopes so each element flips exactly once.
1557
+ const toFlip = new Set()
1558
+ flipRoots.forEach((scope) => {
1559
+ if (!scope) return
1560
+ gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
1561
+ .forEach((el) => { if (el.isConnected) toFlip.add(el) })
1562
+ })
1563
+ toFlip.forEach(playFlip)
1564
+ // Refresh baselines for any .flip that settled this frame.
1565
+ gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
1566
+ flipRoots = new Set()
1567
+ }
1568
+ let flipObserver = null
1569
+ // flipObserver is created conditionally below (hub vs direct)
1337
1570
 
1338
1571
 
1339
1572
 
@@ -1361,9 +1594,9 @@ export default function initListeners() {
1361
1594
  const elEase = easeClass ? easeClass.split("-")[1] : "none"
1362
1595
  el._spawnTween = config.typewriterSplit
1363
1596
  ? playTypewriterSplit(el, delay, duration, elEase)
1364
- : config.play(el, delay, duration, elEase)
1597
+ : invokePlay(config, el, delay, duration, elEase)
1365
1598
  } else {
1366
- el._spawnTween = config.play(el, delay, duration, ease)
1599
+ el._spawnTween = invokePlay(config, el, delay, duration, ease)
1367
1600
  el._spawnTween.eventCallback("onComplete", () => {
1368
1601
  if (el.classList.contains("leave")) refreshLeaveRect(el)
1369
1602
  if (el.classList.contains("flip")) captureFlip(el)
@@ -1372,7 +1605,23 @@ export default function initListeners() {
1372
1605
  }
1373
1606
  }
1374
1607
 
1375
- const appearObserver = new MutationObserver((mutations) => {
1608
+ let appearObserver = null
1609
+ let leaveObserver = null
1610
+ let hubObserver = null
1611
+ let hubRaf = null
1612
+ let hubQueue = []
1613
+ let hubCurrentBatch = null
1614
+ let hubCursor = 0
1615
+
1616
+ const handleFlipBatch = (mutations) => {
1617
+ for (const mutation of mutations) {
1618
+ if (mutation.type !== "childList") continue
1619
+ const target = mutation.target
1620
+ if (target.nodeType !== 1) continue
1621
+ flipRoots.add(target)
1622
+ }
1623
+ }
1624
+ const handleAppearBatch = (mutations) => {
1376
1625
  mutations.forEach((mutation) => {
1377
1626
  mutation.addedNodes.forEach((node) => {
1378
1627
  if (node.nodeType !== 1) return
@@ -1402,20 +1651,77 @@ export default function initListeners() {
1402
1651
  if (pinned) ScrollTrigger.refresh()
1403
1652
  })
1404
1653
  })
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) => {
1654
+ }
1655
+ const handleLeaveBatch = (mutations) => {
1412
1656
  mutations.forEach((mutation) => {
1413
1657
  if (mutation.type !== "childList") return
1414
1658
  mutation.addedNodes.forEach((n) => collectLeave(n).forEach(captureLeave))
1415
1659
  mutation.removedNodes.forEach((n) => collectLeave(n).forEach(playLeave))
1416
1660
  })
1417
- })
1418
- leaveObserver.observe(document.body, { childList: true, subtree: true })
1661
+ }
1662
+ const hubHandlers = [handleFlipBatch, handleAppearBatch, handleLeaveBatch]
1663
+ const hubDrain = () => {
1664
+ hubRaf = null
1665
+ if (!hubCurrentBatch) {
1666
+ if (!hubQueue.length) return
1667
+ hubCurrentBatch = hubQueue.splice(0, hubQueue.length)
1668
+ hubCursor = 0
1669
+ }
1670
+ const end = Math.min(hubCursor + throttlePerFrame, hubHandlers.length)
1671
+ for (let i = hubCursor; i < end; i++) {
1672
+ hubHandlers[i](hubCurrentBatch)
1673
+ if (hubHandlers[i] === handleFlipBatch) flushFlipRoots()
1674
+ }
1675
+ hubCursor = end
1676
+ if (hubCursor < hubHandlers.length) {
1677
+ hubRaf = requestAnimationFrame(hubDrain)
1678
+ } else {
1679
+ hubCurrentBatch = null
1680
+ hubCursor = 0
1681
+ if (hubQueue.length) hubRaf = requestAnimationFrame(hubDrain)
1682
+ }
1683
+ }
1684
+ const scheduleHub = () => {
1685
+ if (hubRaf) return
1686
+ hubRaf = requestAnimationFrame(hubDrain)
1687
+ }
1688
+
1689
+ if (throttlePerFrame > 0) {
1690
+ hubObserver = new MutationObserver((mutations) => {
1691
+ hubQueue.push(...mutations)
1692
+ scheduleHub()
1693
+ })
1694
+ hubObserver.observe(document.body, { childList: true, subtree: true })
1695
+ } else {
1696
+ flipObserver = new MutationObserver((mutations) => {
1697
+ handleFlipBatch(mutations)
1698
+ if (!flipPendingRaf) {
1699
+ flipPendingRaf = requestAnimationFrame(() => {
1700
+ flipPendingRaf = null
1701
+ flushFlipRoots()
1702
+ })
1703
+ }
1704
+ })
1705
+ flipObserver.observe(document.body, { childList: true, subtree: true })
1706
+
1707
+ appearObserver = new MutationObserver((mutations) => {
1708
+ handleAppearBatch(mutations)
1709
+ })
1710
+ appearObserver.observe(document.body, { childList: true, subtree: true })
1711
+
1712
+ // Capture any .leave elements already present so they can exit later
1713
+ qAll(".leave").forEach(captureLeave)
1714
+
1715
+ leaveObserver = new MutationObserver((mutations) => {
1716
+ handleLeaveBatch(mutations)
1717
+ })
1718
+ leaveObserver.observe(document.body, { childList: true, subtree: true })
1719
+ // leave capture for throttled path is done below after branch
1720
+ }
1721
+ if (throttlePerFrame > 0) {
1722
+ // capture for throttled path (was inside else branch above for non-throttled)
1723
+ qAll(".leave").forEach(captureLeave)
1724
+ }
1419
1725
 
1420
1726
  // Keep the captured position fresh (throttled to one pass per frame)
1421
1727
  let positionTick = false
@@ -1423,7 +1729,7 @@ export default function initListeners() {
1423
1729
  if (positionTick) return
1424
1730
  positionTick = true
1425
1731
  requestAnimationFrame(() => {
1426
- gsap.utils.toArray(".leave").forEach((el) => {
1732
+ qAll(".leave").forEach((el) => {
1427
1733
  const s = leaveStates.get(el)
1428
1734
  if (s) s.rect = el.getBoundingClientRect()
1429
1735
  })
@@ -1434,27 +1740,76 @@ export default function initListeners() {
1434
1740
  window.addEventListener("resize", refreshLeavePositions, { passive: true })
1435
1741
 
1436
1742
  return () => {
1437
- appearObserver.disconnect()
1438
- leaveObserver.disconnect()
1439
- flipObserver.disconnect()
1743
+ appearObserver?.disconnect()
1744
+ leaveObserver?.disconnect()
1745
+ flipObserver?.disconnect()
1746
+ if (hubObserver) hubObserver.disconnect()
1747
+ if (hubRaf) cancelAnimationFrame(hubRaf)
1748
+ if (flipPendingRaf) cancelAnimationFrame(flipPendingRaf)
1440
1749
  window.removeEventListener("scroll", refreshLeavePositions)
1441
1750
  window.removeEventListener("resize", refreshLeavePositions)
1442
1751
  window.removeEventListener("load", ScrollTrigger.refresh)
1443
1752
  clearTimeout(refreshTimer)
1444
1753
  scrollTriggers.forEach((t) => {
1445
- t.kill()
1754
+ const tw = t.trigger._scrollTween
1755
+ // Finalize a mid-flight typewriter/scramble entrance at its end state
1756
+ // BEFORE killing: stranded partial/garbled text would otherwise be read as
1757
+ // settled content by the next run's stash.
1758
+ const cls = t.trigger.classList
1759
+ if (tw && !tw.reversed() && (cls?.contains("typewriter") || cls?.contains("scramble"))) tw.progress(1)
1760
+ // kill(true): revert pinning (remove pin-spacers, restore inline
1761
+ // styles) so a later init can re-pin cleanly instead of nesting a
1762
+ // second spacer inside the leaked first one.
1763
+ t.kill(true)
1446
1764
  t.trigger._scrollTween?.kill()
1447
1765
  delete t.trigger._scrollTween
1448
1766
  })
1449
1767
  ScrollTrigger.refresh()
1768
+ // Clear per-element wire-up tags. Without this, any engine restart
1769
+ // (initAnimations() called again, StrictMode remounts, route-level
1770
+ // re-mounts that keep DOM nodes alive) would silently skip rewiring:
1771
+ // .scroll/.pin/parallax triggers stay dead (their old ones were just
1772
+ // killed) and clicks/loops/hover never re-bind. `_appeared` goes too,
1773
+ // so dynamically-added .appear elements can animate under the new
1774
+ // engine run. data-gsap-preserved (cross-reset memory) and
1775
+ // data-gsap-ghost (detached .leave clones) are intentionally KEPT.
1776
+ qAll('[data-gsap-scroll],[data-gsap-setup],[data-gsap-pinned],[data-gsap-scroll-driven]').forEach((el) => {
1777
+ delete el.dataset.gsapScroll
1778
+ delete el.dataset.gsapSetup
1779
+ delete el.dataset.gsapPinned
1780
+ delete el.dataset.gsapScrollDriven
1781
+ delete el._appeared
1782
+ })
1450
1783
  registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1451
1784
  magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1452
1785
  magnetQuery?.removeEventListener("change", applyMagnet)
1453
1786
  loopEls.forEach(({ el, key }) => el[key]?.kill())
1787
+ // Sweep any radiate clones orphaned by pre-fix kills or edge cases.
1788
+ document.querySelectorAll('[data-gsap-radiate]').forEach((n) => n.remove())
1454
1789
  cssTweens.forEach((t) => t?.kill())
1455
1790
  cssTweens.length = 0
1456
- gsap.utils.toArray(".typewriter").forEach(el => el.typewriter?.kill())
1457
- textSplits.forEach((s) => s.revert())
1791
+ qAll(".typewriter, .scramble").forEach(el => {
1792
+ // Preserved-region typewriters/scrambles stay as-is (already at
1793
+ // their end state); finalize the rest so a mid-type/mid-scramble
1794
+ // kill can't strand partial or garbled text where the next run's
1795
+ // stash would read it.
1796
+ const keep = el.isConnected && underPreservedRoot(el)
1797
+ const tw = el.typewriter || el._spawnTween || el._scrollTween
1798
+ if (!keep && tw && !tw.reversed()) tw.progress(1)
1799
+ tw?.kill()
1800
+ })
1801
+ textSplits.forEach((s) => {
1802
+ // Splits inside a tagged preserve region keep their spans - the
1803
+ // next run will skip those elements, and reverting here would
1804
+ // visibly strip their finished animation. Everything else reverts
1805
+ // cleanly (and drops out of splitCache so a reused element can be
1806
+ // re-split fresh).
1807
+ const keep = (s.elements || []).some((e) => e.isConnected && underPreservedRoot(e))
1808
+ if (!keep) {
1809
+ ;(s.elements || []).forEach((e) => splitCache.delete(e))
1810
+ revertSplitInstance(s)
1811
+ }
1812
+ })
1458
1813
  textSplits.length = 0
1459
1814
  onCompleteTweens.forEach((t) => t?.kill())
1460
1815
  onCompleteTweens.length = 0