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 +71 -23
- package/Config.js +2 -2
- package/Listeners.js +83 -17
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
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.
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1081
|
-
//
|
|
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.
|
|
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 =>
|
|
1457
|
-
|
|
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.
|
|
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",
|