gclass-anims 1.0.0-beta.6 → 1.0.0-beta.8

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/Animations.js CHANGED
@@ -28,6 +28,23 @@ export const finalOpacity = (target) => {
28
28
  return isNaN(v) ? 1 : v
29
29
  }
30
30
 
31
+ // TextPlugin tweens take their endpoints from the LIVE DOM: the `.typewriter`
32
+ // play callbacks pass `el.innerHTML` as the text to type. The tween's from
33
+ // state ("") is applied the instant the tween is created, and a teardown that
34
+ // kills the tween mid-flight leaves that wiped state behind — so a later
35
+ // engine re-init reading `el.innerHTML` again would type an empty (or
36
+ // partially-typed) string forever. Stash the full HTML on first sight and
37
+ // reuse it. The stash only refreshes from SETTLED content: never while a
38
+ // typewriter tween on the element is actively rendering partial progress, and
39
+ // never from a blank DOM. Legit content changes (React re-renders, dynamic
40
+ // `.appear` elements) therefore update the stash naturally.
41
+ export const stashText = (el) => {
42
+ const busy = (el.typewriter || el._scrollTween)?.isActive?.()
43
+ const html = el.innerHTML
44
+ if (!busy && html && html.trim()) el._gcText = html
45
+ return el._gcText !== undefined ? el._gcText : html
46
+ }
47
+
31
48
 
32
49
  //Spawn animations
33
50
 
@@ -323,6 +340,9 @@ export function pulse (delay , target , amount , dur , ease){
323
340
 
324
341
  export function radiate (delay , target , amount , dur , ease , zIndex){
325
342
  const clone = target.cloneNode(true)
343
+ // Tagged so engine teardown can sweep up clones whose tween was killed
344
+ // before its onComplete (route changes mid-animation).
345
+ clone.setAttribute("data-gsap-radiate", "1")
326
346
  clone.style.cssText = `position:fixed;left:0;top:0;right:auto;bottom:auto;margin:0;pointer-events:none;transform-origin:50% 50%;${zIndex != null ? `z-index:${zIndex};` : ""}`
327
347
  // Keep the ripple glued to the target so it tracks scroll/resize instead of
328
348
  // getting stranded at the position captured when the animation was built.
@@ -346,6 +366,13 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
346
366
  applyRect()
347
367
  window.addEventListener("scroll", schedule, { passive: true })
348
368
  window.addEventListener("resize", schedule, { passive: true })
369
+ // Killing the tween (teardown, hover/click rebuilds) must clean up exactly
370
+ // like natural completion — otherwise clones + listeners leak.
371
+ const cleanup = () => {
372
+ clone.remove()
373
+ window.removeEventListener("scroll", schedule)
374
+ window.removeEventListener("resize", schedule)
375
+ }
349
376
 
350
377
  return gsap.fromTo(clone , {scale:1 , opacity:1} , {
351
378
  scale:amount / 10 ,
@@ -353,11 +380,8 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
353
380
  duration:dur ,
354
381
  delay:delay ,
355
382
  ease:easeOf(ease) ,
356
- onComplete: () => {
357
- clone.remove()
358
- window.removeEventListener("scroll", schedule)
359
- window.removeEventListener("resize", schedule)
360
- } ,
383
+ onComplete: cleanup ,
384
+ onInterrupt: cleanup ,
361
385
  })
362
386
  }
363
387
 
@@ -383,28 +407,52 @@ export function marquee (target , dir , duration , xOffset = 0 , yOffset = 0 , n
383
407
  // which opens a gap on the trailing edge at some point in the loop.
384
408
  target.style.position = "relative"
385
409
  target.style.overflow = "hidden"
386
- const track = document.createElement("div")
387
- track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
388
- while (target.firstChild) track.appendChild(target.firstChild)
389
- target.appendChild(track)
390
-
391
- // The track is absolutely positioned, so once its content moves in, the host
392
- // has no in-flow children left and can collapse to zero height. With the
393
- // `overflow:hidden` set above that clips the track away entirely (e.g. a
394
- // bare-text `<h1 class="marquee-left">` disappears). Preserve the content's
395
- // height on the host when that happens so the marquee stays visible. Hosts
396
- // with their own height (flex cards etc.) are left untouched.
410
+
411
+ // Rebuilds over the SAME element (engine restarts, StrictMode remounts)
412
+ // must reuse the existing track. Re-creating it would swallow the old
413
+ // absolute track as the first "child", repeat THAT as the tiling unit —
414
+ // every copy stacks at the same offset and scrollWidth collapses.
415
+ let track = target._gcTrack
416
+ if (!track || !track.isConnected) {
417
+ track = document.createElement("div")
418
+ track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
419
+ while (target.firstChild) track.appendChild(target.firstChild)
420
+ target.appendChild(track)
421
+ // Guard against legacy poisoned DOM: older builds could wrap a previous
422
+ // absolute track inside this one. Unwrap any nested engine tracks so
423
+ // the stashed unit is always the raw content.
424
+ while (
425
+ track.children.length &&
426
+ [...track.children].every((c) => c.style.position === "absolute" && c.style.display === "flex")
427
+ ) {
428
+ const inner = track.firstElementChild
429
+ while (inner.firstChild) track.insertBefore(inner.firstChild, inner)
430
+ track.removeChild(inner)
431
+ }
432
+ target._gcUnitHtml = track.innerHTML
433
+ }
434
+ track.style.flexDirection = horizontal ? "row" : "column"
435
+ const unitHtml = target._gcUnitHtml
436
+
437
+ // Measure ONE unit on its own: the live track may already hold N copies
438
+ // (or stale content), which would inflate unitSize and starve `copies`.
439
+ const measurer = document.createElement("div")
440
+ measurer.style.cssText = track.style.cssText + "visibility:hidden;"
441
+ measurer.innerHTML = unitHtml
442
+ target.appendChild(measurer)
443
+ const unitSize = horizontal ? measurer.scrollWidth : measurer.scrollHeight
444
+ measurer.remove()
445
+
446
+ // Nothing to tile (empty content) — return an inert tween rather than one
447
+ // dividing by a zero-width unit.
448
+ if (!unitSize) return gsap.fromTo(track, {}, { duration: 0 })
449
+
450
+ // The track is absolutely positioned, so a bare-text host can collapse to
451
+ // zero height and `overflow:hidden` would clip the strip away entirely.
397
452
  if (target.offsetHeight === 0 && track.offsetHeight > 0) {
398
453
  target.style.height = track.offsetHeight + "px"
399
454
  }
400
455
 
401
- const first = track.children[0]
402
- if (!first) return gsap.fromTo(track, {}, { duration: 0 })
403
-
404
- // A single copy of the content (the width one loop step must travel).
405
- const unitHtml = track.innerHTML
406
- const unitSize = horizontal ? track.scrollWidth : track.scrollHeight
407
-
408
456
  // Default: repeat the unit until the whole strip is at least as wide as the
409
457
  // viewport (plus one extra copy so the trailing edge stays covered mid-loop).
410
458
  // `.marquee-no-repeat` opts into the minimal 2-copy single-seam behaviour.
package/Config.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  spinCCW, spinCW, expandA, typewriter, bell, spawnBlur, spawnFade, spawnXDown,
4
4
  spawnXUp, spawnYRight, spawnYLeft, pulse, radiate, hover, expandRight,
5
5
  expandLeft, expandUp, expandDown, marquee, countUp,
6
- spawnClipReveal, curtainHorizontal, curtainVertical,
6
+ spawnClipReveal, curtainHorizontal, curtainVertical, stashText,
7
7
  } from './Animations.js'
8
8
 
9
9
  // ---------------------------------------------------------------------------
@@ -91,7 +91,7 @@ export const animations = [
91
91
  { sel: ".expand-up", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandUp(el, delay, dur, ease) },
92
92
  { sel: ".expand-down", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandDown(el, delay, dur, ease) },
93
93
  { sel: ".expand-all", from: { opacity: 0, scale: 0 }, play: (el, delay, dur, ease) => expandA(el, delay, dur, ease) },
94
- { sel: ".typewriter", typewriter: true, from: { text: "" }, play: (el, delay, dur, ease) => typewriter(el, el.innerHTML, dur, delay, ease) },
94
+ { sel: ".typewriter", typewriter: true, from: { text: "" }, play: (el, delay, dur, ease) => typewriter(el, stashText(el), dur, delay, ease) },
95
95
  { sel: ".typewriter-split", typewriter: true, typewriterSplit: true, from: { opacity: 0 }, play: (el, delay, dur, ease) => null },
96
96
 
97
97
  // Custom-function animation: counts from the `.spawn-num-N` value (N = the
package/Listeners.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import gsap from 'gsap'
2
- import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars } from './Animations'
2
+ import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars, stashText } from './Animations'
3
3
  import { customAnims } from './CustomAnims'
4
4
  import { defaults, normalize } from './Config'
5
5
  import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
@@ -57,19 +57,37 @@ export default function initListeners() {
57
57
 
58
58
  // `.preserve` keeps an already-rendered element (e.g. one that persists
59
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.
60
+ // the Listeners setup re-runs. It applies to the element AND its
61
+ // children: any preserved ancestor also suppresses animation on this
62
+ // node. Two subtleties make this behave correctly:
63
+ // markPreserved() tags the FULL preserve-ancestor chain, so a bare
64
+ // `.preserve` container without its own spawn class (a site header,
65
+ // say) still gets tagged when one of its children animates.
66
+ // • Suppression requires the element ITSELF to carry data-gsap-wired
67
+ // (set when a previous run animated it). Freshly mounted content
68
+ // under a preserved root therefore still plays its entrance — only
69
+ // DOM that survived from an earlier run stays frozen.
66
70
  const isPreserved = (el) => {
71
+ if (!el.dataset.gsapWired) return false
72
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
73
+ if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
74
+ }
75
+ return false
76
+ }
77
+ const markPreserved = (el) => {
78
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
79
+ if (node.classList.contains("preserve")) node.dataset.gsapPreserved = "1"
80
+ }
81
+ }
82
+ // True when el sits inside a `.preserve` root that a run has already
83
+ // tagged. Such regions are frozen by design: later runs skip them, so
84
+ // teardown must leave their visual state untouched too.
85
+ const underPreservedRoot = (el) => {
67
86
  for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
68
87
  if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
69
88
  }
70
89
  return false
71
90
  }
72
- const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
73
91
 
74
92
  // `spawnConfigs` is derived from the config in Config.js (see top of
75
93
  // file). Adding/removing an entry there automatically re-wires every
@@ -699,7 +717,7 @@ export default function initListeners() {
699
717
  scrollTriggers.push(t.scrollTrigger)
700
718
  }
701
719
  }
702
- gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
720
+ gsap.utils.toArray('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
703
721
 
704
722
  // `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
705
723
  // Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
@@ -724,7 +742,7 @@ export default function initListeners() {
724
742
  for (const [key] of Object.entries(from)) {
725
743
  if (key === "opacity") to[key] = 1
726
744
  else if (key === "filter") to[key] = "blur(0px)"
727
- else if (key === "text") to[key] = el.innerHTML
745
+ else if (key === "text") to[key] = stashText(el)
728
746
  else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
729
747
  else to[key] = key.startsWith("scale") ? 1 : 0
730
748
  }
@@ -767,7 +785,7 @@ export default function initListeners() {
767
785
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
768
786
  : getEase(el)
769
787
 
770
- const fullText = el.innerHTML
788
+ const fullText = stashText(el)
771
789
 
772
790
  const enter = () => {
773
791
  if (el._scrollTween) el._scrollTween.kill()
@@ -902,7 +920,7 @@ export default function initListeners() {
902
920
  el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
903
921
  } else {
904
922
  el.typewriter?.kill()
905
- el.typewriter = typewriter(el, el.innerHTML, duration, delay, elEase)
923
+ el.typewriter = typewriter(el, stashText(el), duration, delay, elEase)
906
924
  }
907
925
  } else {
908
926
  el._spawnTween = play(el, delay, duration, getEase(el))
@@ -915,6 +933,7 @@ export default function initListeners() {
915
933
  })
916
934
  }
917
935
  markPreserved(el)
936
+ el.dataset.gsapWired = "1"
918
937
  })
919
938
  })
920
939
 
@@ -931,6 +950,7 @@ export default function initListeners() {
931
950
  const { delay, duration } = readTiming(el)
932
951
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
933
952
  markPreserved(el)
953
+ el.dataset.gsapWired = "1"
934
954
  })
935
955
  })
936
956
 
@@ -1077,8 +1097,11 @@ export default function initListeners() {
1077
1097
  el[key]?.kill()
1078
1098
  el[key] = trackCompatLoop(el, build(el, ctx))
1079
1099
  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.
1100
+ // Track wired loops so teardown can kill them (radiate
1101
+ // relies on kill/onInterrupt to remove its clones).
1102
+ if (!loopEls.some((l) => l.el === el && l.key === key)) {
1103
+ loopEls.push({ el, key })
1104
+ }
1082
1105
  el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
1083
1106
  () => fireOnComplete(el, "loop"))
1084
1107
  }
@@ -1442,19 +1465,62 @@ export default function initListeners() {
1442
1465
  window.removeEventListener("load", ScrollTrigger.refresh)
1443
1466
  clearTimeout(refreshTimer)
1444
1467
  scrollTriggers.forEach((t) => {
1445
- t.kill()
1468
+ const tw = t.trigger._scrollTween
1469
+ // Finalize a mid-flight typewriter entrance at its end state
1470
+ // BEFORE killing: stranded partial text would otherwise be read as
1471
+ // settled content by the next run's stash.
1472
+ if (tw && !tw.reversed() && t.trigger.classList?.contains("typewriter")) tw.progress(1)
1473
+ // kill(true): revert pinning (remove pin-spacers, restore inline
1474
+ // styles) so a later init can re-pin cleanly instead of nesting a
1475
+ // second spacer inside the leaked first one.
1476
+ t.kill(true)
1446
1477
  t.trigger._scrollTween?.kill()
1447
1478
  delete t.trigger._scrollTween
1448
1479
  })
1449
1480
  ScrollTrigger.refresh()
1481
+ // Clear per-element wire-up tags. Without this, any engine restart
1482
+ // (initAnimations() called again, StrictMode remounts, route-level
1483
+ // re-mounts that keep DOM nodes alive) would silently skip rewiring:
1484
+ // .scroll/.pin/parallax triggers stay dead (their old ones were just
1485
+ // killed) and clicks/loops/hover never re-bind. `_appeared` goes too,
1486
+ // so dynamically-added .appear elements can animate under the new
1487
+ // engine run. data-gsap-preserved (cross-reset memory) and
1488
+ // data-gsap-ghost (detached .leave clones) are intentionally KEPT.
1489
+ gsap.utils.toArray('[data-gsap-scroll],[data-gsap-setup],[data-gsap-pinned],[data-gsap-scroll-driven]').forEach((el) => {
1490
+ delete el.dataset.gsapScroll
1491
+ delete el.dataset.gsapSetup
1492
+ delete el.dataset.gsapPinned
1493
+ delete el.dataset.gsapScrollDriven
1494
+ delete el._appeared
1495
+ })
1450
1496
  registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1451
1497
  magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1452
1498
  magnetQuery?.removeEventListener("change", applyMagnet)
1453
1499
  loopEls.forEach(({ el, key }) => el[key]?.kill())
1500
+ // Sweep any radiate clones orphaned by pre-fix kills or edge cases.
1501
+ document.querySelectorAll('[data-gsap-radiate]').forEach((n) => n.remove())
1454
1502
  cssTweens.forEach((t) => t?.kill())
1455
1503
  cssTweens.length = 0
1456
- gsap.utils.toArray(".typewriter").forEach(el => el.typewriter?.kill())
1457
- textSplits.forEach((s) => s.revert())
1504
+ gsap.utils.toArray(".typewriter").forEach(el => {
1505
+ // Preserved-region typewriters stay as-is (already at their end
1506
+ // state); finalize the rest so a mid-type kill can't strand
1507
+ // partial text where the next run's stash would read it.
1508
+ const keep = el.isConnected && underPreservedRoot(el)
1509
+ if (!keep && el.typewriter && !el.typewriter.reversed()) el.typewriter.progress(1)
1510
+ el.typewriter?.kill()
1511
+ })
1512
+ textSplits.forEach((s) => {
1513
+ // Splits inside a tagged preserve region keep their spans — the
1514
+ // next run will skip those elements, and reverting here would
1515
+ // visibly strip their finished animation. Everything else reverts
1516
+ // cleanly (and drops out of splitCache so a reused element can be
1517
+ // re-split fresh).
1518
+ const keep = (s.elements || []).some((e) => e.isConnected && underPreservedRoot(e))
1519
+ if (!keep) {
1520
+ ;(s.elements || []).forEach((e) => splitCache.delete(e))
1521
+ s.revert()
1522
+ }
1523
+ })
1458
1524
  textSplits.length = 0
1459
1525
  onCompleteTweens.forEach((t) => t?.kill())
1460
1526
  onCompleteTweens.length = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gclass-anims",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "A Tailwind-style utility layer on top of GSAP. Framework-agnostic — works in vanilla JS, React, Vue, Svelte, or any bundler.",
5
5
  "type": "module",
6
6
  "main": "index.js",