gclass-anims 1.0.0-beta.1 → 1.0.0-beta.11

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
@@ -1,9 +1,13 @@
1
- import { Flip, SplitText, TextPlugin } from "gsap/all";
1
+ import { DrawSVGPlugin, Flip, MotionPathPlugin, ScrambleTextPlugin, SplitText, TextPlugin } from "gsap/all";
2
2
  import gsap from "gsap";
3
+ import { defaults } from './Config.js'
3
4
 
4
5
  gsap.registerPlugin(Flip)
5
6
  gsap.registerPlugin(SplitText)
6
7
  gsap.registerPlugin(TextPlugin)
8
+ gsap.registerPlugin(DrawSVGPlugin)
9
+ gsap.registerPlugin(MotionPathPlugin)
10
+ gsap.registerPlugin(ScrambleTextPlugin)
7
11
 
8
12
  // A tasteful fallback whenever a call site omits an ease, so the animation
9
13
  // never lapses into the raw "none" look. Callers still override this freely.
@@ -28,6 +32,23 @@ export const finalOpacity = (target) => {
28
32
  return isNaN(v) ? 1 : v
29
33
  }
30
34
 
35
+ // TextPlugin tweens take their endpoints from the LIVE DOM: the `.typewriter`
36
+ // play callbacks pass `el.innerHTML` as the text to type. The tween's from
37
+ // state ("") is applied the instant the tween is created, and a teardown that
38
+ // kills the tween mid-flight leaves that wiped state behind — so a later
39
+ // engine re-init reading `el.innerHTML` again would type an empty (or
40
+ // partially-typed) string forever. Stash the full HTML on first sight and
41
+ // reuse it. The stash only refreshes from SETTLED content: never while a
42
+ // typewriter tween on the element is actively rendering partial progress, and
43
+ // never from a blank DOM. Legit content changes (React re-renders, dynamic
44
+ // `.appear` elements) therefore update the stash naturally.
45
+ export const stashText = (el) => {
46
+ const busy = (el.typewriter || el._spawnTween || el._scrollTween)?.isActive?.()
47
+ const html = el.innerHTML
48
+ if (!busy && html && html.trim()) el._gcText = html
49
+ return el._gcText !== undefined ? el._gcText : html
50
+ }
51
+
31
52
 
32
53
  //Spawn animations
33
54
 
@@ -201,6 +222,188 @@ export function countUp (target , delay , dur, ease){
201
222
  return gsap.timeline({ delay }).fromTo(obj , { n: start } , { n: end , duration:dur , ease:e , onUpdate: () => { target.textContent = obj.n.toFixed(decimals) } } , 0)
202
223
  }
203
224
 
225
+ // Stroke-draw reveal (strokes only — filled SVGs are deliberately out of
226
+ // scope for now). Explicit fromTo endpoints so the animation's hidden state
227
+ // matches this class's Config `from` metadata exactly: `.scroll-progress`
228
+ // scrubs between those two values and `.leave`/`.scroll` reversal tweens back
229
+ // into them.
230
+ export function drawsvg (target , delay , dur , ease){
231
+ const e = easeOf(ease)
232
+ return gsap.fromTo(target , {drawSVG:"0%"} , {ease:e , duration:dur , delay:delay , drawSVG:"100%"})
233
+ }
234
+
235
+ // Busts a multi-segment <path> (one containing multiple "M" commands) apart
236
+ // into one single-segment <path> per segment. Browsers can't reliably render
237
+ // a stroke-dash progressive reveal across disconnected subpaths, while
238
+ // separate paths draw correctly. Adapted from the official DrawSVGPlugin
239
+ // helper, with one addition: splitting REPLACES the source path in the DOM,
240
+ // so the result is cached on that element and reused while the segments are
241
+ // still live — an engine re-init (StrictMode remount, route change) must not
242
+ // churn the DOM a second time. Attributes are copied verbatim; filled SVGs
243
+ // are simply untouched territory for now.
244
+ export function splitPaths (paths){
245
+ const toSplit = gsap.utils.toArray(paths)
246
+ let newPaths = []
247
+ if (toSplit.length > 1) {
248
+ toSplit.forEach(path => newPaths.push(...splitPaths(path)))
249
+ return newPaths
250
+ }
251
+ const path = toSplit[0]
252
+ if (!path) return newPaths
253
+ if (path._gcSplitPaths?.[0]?.isConnected) return path._gcSplitPaths
254
+ const rawPath = MotionPathPlugin.getRawPath(path)
255
+ const parent = path.parentNode
256
+ const attributes = [...path.attributes]
257
+ newPaths = rawPath.map(segment => {
258
+ const newPath = document.createElementNS("http://www.w3.org/2000/svg" , "path")
259
+ let i = attributes.length
260
+ while (i--) {
261
+ const attr = attributes[i]
262
+ // Don't copy GSAP wiring or appear/scroll triggers — children are
263
+ // animated via the returned timeline, not as independent spawns.
264
+ // Copying "appear" caused appearObserver → split → appear loop.
265
+ if (attr.nodeName === "class") {
266
+ const filtered = attr.nodeValue
267
+ .split(/\s+/)
268
+ .filter(c => c && c !== "appear" && c !== "scroll" && c !== "scroll-progress" && c !== "draw" && c !== "draw-split")
269
+ .join(" ")
270
+ if (filtered) newPath.setAttributeNS(null, "class", filtered)
271
+ continue
272
+ }
273
+ if (attr.nodeName.startsWith("data-gsap")) continue
274
+ newPath.setAttributeNS(null , attr.nodeName , attr.nodeValue)
275
+ }
276
+ newPath.setAttributeNS(null , "d" ,
277
+ "M" + segment[0] + "," + segment[1] +
278
+ "C" + segment.slice(2).join(",") +
279
+ (segment.closed ? "z" : ""))
280
+ // Isolate paint and mark as split child so future inits skip it
281
+ newPath.dataset.gsapSplit = "1"
282
+ newPath.style.contain = "paint"
283
+ newPath.style.willChange = "transform"
284
+ parent.insertBefore(newPath , path)
285
+ return newPath
286
+ })
287
+ parent.removeChild(path)
288
+ return path._gcSplitPaths = newPaths
289
+ }
290
+
291
+ // Like drawsvg but built for MULTI-SEGMENT paths: splitPaths() first, then
292
+ // draw each resulting segment one after another, giving every segment a slice
293
+ // of `dur` proportional to its own stroke length so the pen travels at a
294
+ // constant speed across the whole drawing. Returns a timeline, so leave /
295
+ // scroll reversal un-draws the segments back-to-front and the engine's
296
+ // onComplete hooks fire only after the final segment lands.
297
+ export function drawsvgSplit (target , delay , dur , ease){
298
+ const e = easeOf(ease)
299
+ const tl = gsap.timeline({ delay })
300
+ const paths = splitPaths(target)
301
+ let distance = 0
302
+ paths.forEach(segment => distance += segment.getTotalLength())
303
+ // Nothing drawable (empty selection / zero-length strokes): hand back the
304
+ // inert timeline rather than divide by zero below.
305
+ if (!distance) return tl
306
+ paths.forEach(segment => {
307
+ tl.fromTo(segment ,
308
+ {drawSVG:"0%"} ,
309
+ {ease:e , duration:dur * (segment.getTotalLength() / distance) , drawSVG:"100%"})
310
+ })
311
+ return tl
312
+ }
313
+
314
+ // Scramble plumbing. Only the element's TOP-LEVEL TEXT runs are scrambled:
315
+ // each run is wrapped in its own span and tweened separately, while real child
316
+ // elements (links, icons, ...) are left completely untouched — their markup
317
+ // survives the animation intact. Wraps are cached on the element so replays
318
+ // (engine re-inits, .appear re-triggers) reuse the same spans instead of
319
+ // churning the DOM.
320
+ export const scrambleSegments = (target) => {
321
+ let wraps = target._gcScrambleSegs
322
+ if (!wraps || !wraps.length || !wraps.every((w) => w.parentNode === target)) {
323
+ wraps = []
324
+ ;[...target.childNodes].forEach((node) => {
325
+ // Whitespace-only runs stay bare so natural spacing is preserved;
326
+ // everything else becomes an individually scrambable span.
327
+ if (node.nodeType !== 3 || !node.textContent.trim()) return
328
+ // ScrambleTextPlugin TRIMS its targets, so a span holding
329
+ // " with a " would resolve to "with a" and swallow the spaces
330
+ // around a neighbouring element. Split the edge whitespace off
331
+ // into bare text nodes and wrap only the trimmed core.
332
+ const raw = node.textContent
333
+ const core = raw.trim()
334
+ const leadIdx = raw.indexOf(core[0])
335
+ const trailStart = leadIdx + core.length
336
+ const frag = document.createDocumentFragment()
337
+ if (leadIdx > 0) frag.appendChild(document.createTextNode(raw.slice(0 , leadIdx)))
338
+ const span = document.createElement("span")
339
+ span.textContent = core
340
+ frag.appendChild(span)
341
+ if (trailStart < raw.length) frag.appendChild(document.createTextNode(raw.slice(trailStart)))
342
+ target.insertBefore(frag , node)
343
+ target.removeChild(node)
344
+ wraps.push(span)
345
+ })
346
+ target._gcScrambleSegs = wraps
347
+ }
348
+ return wraps.map((w) => ({ t: w , text: w.textContent }))
349
+ }
350
+
351
+ // Reads a scramble element's modifier classes and resolves them against the
352
+ // package defaults:
353
+ // .amount-N -> ScrambleText speed (default 1, GSAP's own default)
354
+ // .reveal-delay-N -> revealDelay in seconds (default defaults.revealDelay)
355
+ // .chars-[...] -> character pool taken verbatim from inside the brackets
356
+ // (default defaults.characterlist)
357
+ export function scrambleVars (target){
358
+ const num = (prefix , fallback) => {
359
+ const match = [...target.classList].find(c => c.startsWith(prefix))
360
+ return match ? Number(match.slice(prefix.length)) : fallback
361
+ }
362
+ // Greedy up to the LAST "]" so pools containing "]" survive intact.
363
+ const charsCls = [...target.classList].find(c => /^chars-\[(.*)\]$/.test(c))
364
+ return {
365
+ segs: scrambleSegments(target) ,
366
+ chars: charsCls ? charsCls.slice("chars-[".length , -1) : defaults.characterlist ,
367
+ speed: num("amount-" , 1) ,
368
+ revealDelay: num("reveal-delay-" , defaults.revealDelay) ,
369
+ // .scramble-rtl flips the reveal direction (ScrambleTextPlugin's
370
+ // rightToLeft) so the sweep travels right -> left.
371
+ rtl: target.classList.contains("scramble-rtl") ,
372
+ }
373
+ }
374
+
375
+ // Scramble spawn: the text starts empty and resolves into the real content
376
+ // through garbage characters — no opacity involved, the scramble IS the
377
+ // reveal. Unlike typewriter there is no opacity fade to hide behind, so the
378
+ // package default "back" ease would visually finish at ~36% of `dur` (back.out
379
+ // crosses ~99% early and the reveal index clamps): unless an explicit ease-*
380
+ // class is present the tween therefore eases linearly, making time-N the TRUE
381
+ // total reveal time. One timeline holds a per-text-run tween at position 0 so
382
+ // leave/scroll reversal and onComplete hooks treat it as a single animation.
383
+ //
384
+ // Variants / modifiers:
385
+ // .scramble-all - no empty-start typing: the already-finished string
386
+ // flips to garbage as a whole and sweeps back (native
387
+ // ScrambleTextPlugin resolve).
388
+ // .scramble-rtl - reveal travels right -> left.
389
+ export function scramble (target , delay , dur , ease){
390
+ const e = [...target.classList].some(c => c.startsWith("ease-")) ? easeOf(ease) : "none"
391
+ const { segs , chars , speed , revealDelay , rtl } = scrambleVars(target)
392
+ const all = target.classList.contains("scramble-all")
393
+ const tl = gsap.timeline({ delay })
394
+ segs.forEach(({ t , text }) => {
395
+ if (all) {
396
+ tl.to(t ,
397
+ {scrambleText:{text , chars , speed , revealDelay , rightToLeft:rtl} , ease:e , duration:dur} , 0)
398
+ } else {
399
+ tl.fromTo(t ,
400
+ {scrambleText:{text:"" , chars}} ,
401
+ {scrambleText:{text , chars , speed , revealDelay , rightToLeft:rtl} , ease:e , duration:dur} , 0)
402
+ }
403
+ })
404
+ return tl
405
+ }
406
+
204
407
 
205
408
  //Mouse animations
206
409
 
@@ -323,6 +526,9 @@ export function pulse (delay , target , amount , dur , ease){
323
526
 
324
527
  export function radiate (delay , target , amount , dur , ease , zIndex){
325
528
  const clone = target.cloneNode(true)
529
+ // Tagged so engine teardown can sweep up clones whose tween was killed
530
+ // before its onComplete (route changes mid-animation).
531
+ clone.setAttribute("data-gsap-radiate", "1")
326
532
  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
533
  // Keep the ripple glued to the target so it tracks scroll/resize instead of
328
534
  // getting stranded at the position captured when the animation was built.
@@ -346,6 +552,13 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
346
552
  applyRect()
347
553
  window.addEventListener("scroll", schedule, { passive: true })
348
554
  window.addEventListener("resize", schedule, { passive: true })
555
+ // Killing the tween (teardown, hover/click rebuilds) must clean up exactly
556
+ // like natural completion — otherwise clones + listeners leak.
557
+ const cleanup = () => {
558
+ clone.remove()
559
+ window.removeEventListener("scroll", schedule)
560
+ window.removeEventListener("resize", schedule)
561
+ }
349
562
 
350
563
  return gsap.fromTo(clone , {scale:1 , opacity:1} , {
351
564
  scale:amount / 10 ,
@@ -353,11 +566,8 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
353
566
  duration:dur ,
354
567
  delay:delay ,
355
568
  ease:easeOf(ease) ,
356
- onComplete: () => {
357
- clone.remove()
358
- window.removeEventListener("scroll", schedule)
359
- window.removeEventListener("resize", schedule)
360
- } ,
569
+ onComplete: cleanup ,
570
+ onInterrupt: cleanup ,
361
571
  })
362
572
  }
363
573
 
@@ -383,28 +593,52 @@ export function marquee (target , dir , duration , xOffset = 0 , yOffset = 0 , n
383
593
  // which opens a gap on the trailing edge at some point in the loop.
384
594
  target.style.position = "relative"
385
595
  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.
596
+
597
+ // Rebuilds over the SAME element (engine restarts, StrictMode remounts)
598
+ // must reuse the existing track. Re-creating it would swallow the old
599
+ // absolute track as the first "child", repeat THAT as the tiling unit —
600
+ // every copy stacks at the same offset and scrollWidth collapses.
601
+ let track = target._gcTrack
602
+ if (!track || !track.isConnected) {
603
+ track = document.createElement("div")
604
+ track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
605
+ while (target.firstChild) track.appendChild(target.firstChild)
606
+ target.appendChild(track)
607
+ // Guard against legacy poisoned DOM: older builds could wrap a previous
608
+ // absolute track inside this one. Unwrap any nested engine tracks so
609
+ // the stashed unit is always the raw content.
610
+ while (
611
+ track.children.length &&
612
+ [...track.children].every((c) => c.style.position === "absolute" && c.style.display === "flex")
613
+ ) {
614
+ const inner = track.firstElementChild
615
+ while (inner.firstChild) track.insertBefore(inner.firstChild, inner)
616
+ track.removeChild(inner)
617
+ }
618
+ target._gcUnitHtml = track.innerHTML
619
+ }
620
+ track.style.flexDirection = horizontal ? "row" : "column"
621
+ const unitHtml = target._gcUnitHtml
622
+
623
+ // Measure ONE unit on its own: the live track may already hold N copies
624
+ // (or stale content), which would inflate unitSize and starve `copies`.
625
+ const measurer = document.createElement("div")
626
+ measurer.style.cssText = track.style.cssText + "visibility:hidden;"
627
+ measurer.innerHTML = unitHtml
628
+ target.appendChild(measurer)
629
+ const unitSize = horizontal ? measurer.scrollWidth : measurer.scrollHeight
630
+ measurer.remove()
631
+
632
+ // Nothing to tile (empty content) — return an inert tween rather than one
633
+ // dividing by a zero-width unit.
634
+ if (!unitSize) return gsap.fromTo(track, {}, { duration: 0 })
635
+
636
+ // The track is absolutely positioned, so a bare-text host can collapse to
637
+ // zero height and `overflow:hidden` would clip the strip away entirely.
397
638
  if (target.offsetHeight === 0 && track.offsetHeight > 0) {
398
639
  target.style.height = track.offsetHeight + "px"
399
640
  }
400
641
 
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
642
  // Default: repeat the unit until the whole strip is at least as wide as the
409
643
  // viewport (plus one extra copy so the trailing edge stays covered mid-loop).
410
644
  // `.marquee-no-repeat` opts into the minimal 2-copy single-seam behaviour.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to `gclass-anims` will be documented in this file.
4
+
5
+ ## [1.0.0-beta.11] - 2026-08-26
6
+
7
+ - Fixed `.draw-split` infinite loop when paired with `.appear` — `splitPaths` (`Animations.js:244`) now strips `appear`/`scroll`/`scroll-progress`/`draw`/`draw-split`/`data-gsap-*` from cloned segments, marks children with `data-gsap-split` + `contain:paint`/`will-change:transform` isolation, and prevents `appearObserver` (`Listeners.js:1559`) re-triggering. Also isolated `draw-split` demos in docs.
8
+
9
+ ## [1.0.0-beta.10] - 2026-08-26
10
+
11
+ - Added `.randomize-<prop>-[min]-[max]` — randomize spawn start values per element (e.g. `randomize-rotation-[-90]-[90]`, `randomize-x-[-40]-[40]`). Re-rolls on every replay (`.scroll` re-enter, `.appear`).
12
+ - Added `.draw` — stroke-draw reveal for SVG paths using DrawSVGPlugin (`drawSVG: 0% → 100%`).
13
+ - Added `.draw-split` — draws multi-segment SVG paths sequentially at constant pen speed (splits paths with multiple `M` commands into individual strokes).
14
+ - Added `.scramble` — text resolves from empty through scrambled characters into real content (ScrambleTextPlugin). Supports `.reveal-delay-N`, `.chars-[...]`, `.amount-N`, `.scramble-rtl`.
15
+ - Added `.scramble-all` — variant of scramble with no empty start; the finished string flips to garbage as a whole then sweeps back.
16
+ - Added `.scroll-frame` — use a scrollable container as the ScrollTrigger scroller for nested `.scroll` / `.scroll-progress` elements (innermost `.scroll-frame` ancestor wins).
17
+ - Fixed `spawn-text-*` (SplitText) not working correctly on flex containers — text runs are now wrapped in block containers before splitting to preserve flex layout, spacing, and line grouping.
18
+
19
+ ## [1.0.0-beta.9] - Previous release
20
+
21
+ - See git history for earlier changes.
package/Config.js CHANGED
@@ -3,7 +3,8 @@ 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
+ drawsvg, drawsvgSplit, scramble,
7
8
  } from './Animations.js'
8
9
 
9
10
  // ---------------------------------------------------------------------------
@@ -68,6 +69,8 @@ export const defaults = {
68
69
  textStagger: 0.03,
69
70
  typewriterSplitCharDuration: 0.05,
70
71
  minTextPartDuration: 0.3,
72
+ revealDelay:0,
73
+ characterlist:"AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz"
71
74
  }
72
75
 
73
76
  export const animations = [
@@ -91,9 +94,28 @@ export const animations = [
91
94
  { sel: ".expand-up", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandUp(el, delay, dur, ease) },
92
95
  { sel: ".expand-down", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandDown(el, delay, dur, ease) },
93
96
  { 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) },
97
+ { sel: ".typewriter", typewriter: true, from: { text: "" }, play: (el, delay, dur, ease) => typewriter(el, stashText(el), dur, delay, ease) },
95
98
  { sel: ".typewriter-split", typewriter: true, typewriterSplit: true, from: { opacity: 0 }, play: (el, delay, dur, ease) => null },
96
99
 
100
+ // Scramble reveal: the text resolves out of garbage characters (ScrambleText).
101
+ // No opacity change — the scramble IS the spawn. Only the element's own text
102
+ // runs animate; nested elements (links, icons) are preserved untouched.
103
+ // Deliberately NO `from`: the scramble manages its own DOM (segment spans),
104
+ // so the generic TextPlugin-based reversal would destroy it — `.scroll`
105
+ // exit simply freezes the revealed state and re-entry replays fresh.
106
+ // `scramble: true` gives `.scroll-progress` a true scrub branch (like
107
+ // `.count`), since the generic from/to scrub can't express a text tween.
108
+ // Defaults to a LINEAR ease so time-N is the true total reveal time;
109
+ // an explicit .ease-* class overrides. Modifiers:
110
+ // .reveal-delay-N - seconds of full-garbage hold before chars start locking in
111
+ // .chars-[...] - the character pool, verbatim inside the brackets
112
+ // .amount-N - scramble speed (ScrambleTextPlugin `speed`)
113
+ // .scramble-all - no typing: the finished string flips to garbage as a
114
+ // whole and sweeps back (native plugin resolve)
115
+ // .scramble-rtl - reveal travels right -> left
116
+ { sel: ".scramble", scramble: true, text: false, play: (el, delay, dur, ease) => scramble(el, delay, dur, ease) },
117
+ { sel: ".scramble-all", scramble: true, text: false, play: (el, delay, dur, ease) => scramble(el, delay, dur, ease) },
118
+
97
119
  // Custom-function animation: counts from the `.spawn-num-N` value (N = the
98
120
  // starting number) up to whatever number is in the element (falling back to 0
99
121
  // when no `.spawn-num-N` class is present). `play` just wraps a helper from
@@ -114,6 +136,15 @@ export const animations = [
114
136
  { sel: ".curtain-horizontal", text: false, from: { clipPath: "inset(0% 50% 0% 50%)" }, play: (el, delay, dur, ease) => curtainHorizontal(el, delay, dur, ease) },
115
137
  { sel: ".curtain-vertical", text: false, from: { clipPath: "inset(50% 0% 50% 0%)" }, play: (el, delay, dur, ease) => curtainVertical(el, delay, dur, ease) },
116
138
 
139
+ // Stroke-draw reveals (strokes only — filled SVGs are a separate plan).
140
+ // The hidden state is a fully undrawn stroke (`drawSVG: "0%"`): that's what
141
+ // `.scroll-progress` scrubs up from and what leave/scroll reversal returns
142
+ // to. `.draw` animates its target(s) as one stroke; `.draw-split` first
143
+ // splits multi-segment paths (paths with multiple "M" commands) into one
144
+ // <path> per segment and draws them sequentially at constant pen speed.
145
+ { sel: ".draw", text: false, from: { drawSVG: "0%" }, play: (el, delay, dur, ease) => drawsvg(el, delay, dur, ease) },
146
+ { sel: ".draw-split", text: false, from: { drawSVG: "0%" }, play: (el, delay, dur, ease) => drawsvgSplit(el, delay, dur, ease) },
147
+
117
148
 
118
149
  // --- Loops (build + key). Also usable via hover-<name>/click-<name> ---------
119
150
  { sel: ".shake", build: (el, { edelay, amount, etime, ease }) => shake(edelay, el, amount, etime, ease), key: "shakeanim" },
@@ -147,6 +178,7 @@ export function normalize(extra = []) {
147
178
  typewriterSplit: a.typewriterSplit,
148
179
  text: a.text !== false,
149
180
  count: a.count,
181
+ scramble: a.scramble,
150
182
  }))
151
183
  const loopConfigs = all
152
184
  .filter((a) => a.build)
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, scrambleVars } from './Animations'
3
3
  import { customAnims } from './CustomAnims'
4
4
  import { defaults, normalize } from './Config'
5
5
  import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
@@ -30,6 +30,69 @@ export function resolveHandler(name) {
30
30
  return null
31
31
  }
32
32
 
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
+
33
96
  export default function initListeners() {
34
97
  gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
35
98
 
@@ -57,19 +120,37 @@ export default function initListeners() {
57
120
 
58
121
  // `.preserve` keeps an already-rendered element (e.g. one that persists
59
122
  // 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.
123
+ // the Listeners setup re-runs. It applies to the element AND its
124
+ // children: any preserved ancestor also suppresses animation on this
125
+ // node. Two subtleties make this behave correctly:
126
+ // • markPreserved() tags the FULL preserve-ancestor chain, so a bare
127
+ // `.preserve` container without its own spawn class (a site header,
128
+ // say) still gets tagged when one of its children animates.
129
+ // • Suppression requires the element ITSELF to carry data-gsap-wired
130
+ // (set when a previous run animated it). Freshly mounted content
131
+ // under a preserved root therefore still plays its entrance — only
132
+ // DOM that survived from an earlier run stays frozen.
66
133
  const isPreserved = (el) => {
134
+ if (!el.dataset.gsapWired) return false
135
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
136
+ if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
137
+ }
138
+ return false
139
+ }
140
+ const markPreserved = (el) => {
141
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
142
+ if (node.classList.contains("preserve")) node.dataset.gsapPreserved = "1"
143
+ }
144
+ }
145
+ // True when el sits inside a `.preserve` root that a run has already
146
+ // tagged. Such regions are frozen by design: later runs skip them, so
147
+ // teardown must leave their visual state untouched too.
148
+ const underPreservedRoot = (el) => {
67
149
  for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
68
150
  if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
69
151
  }
70
152
  return false
71
153
  }
72
- const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
73
154
 
74
155
  // `spawnConfigs` is derived from the config in Config.js (see top of
75
156
  // file). Adding/removing an entry there automatically re-wires every
@@ -317,7 +398,7 @@ export default function initListeners() {
317
398
  const delay = readClassNumber(el, "complete-delay-", 0)
318
399
  const dur = readClassNumber(el, "complete-time-", 1)
319
400
  const tween = entry.play
320
- ? entry.play(el, delay, dur, getEase(el))
401
+ ? invokePlay(entry, el, delay, dur, getEase(el))
321
402
  : entry.build(el, readLoopCtx(el))
322
403
  if (!tween) return
323
404
  if (!entry.play) tween.delay(delay)
@@ -337,12 +418,16 @@ export default function initListeners() {
337
418
  }
338
419
 
339
420
  const scrollTriggers = []
421
+ // Derives the fully-visible twin of a spawn's `from` state. drawSVG is
422
+ // inverted relative to the numeric default: its HIDDEN value is 0%, so
423
+ // the drawn end state is the full stroke.
340
424
  const computeTo = (from) => {
341
425
  const to = {}
342
426
  for (const [key] of Object.entries(from)) {
343
427
  if (key === "opacity") to[key] = 1
344
428
  else if (key === "filter") to[key] = "blur(0px)"
345
429
  else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
430
+ else if (key === "drawSVG") to[key] = "100%"
346
431
  else to[key] = key.startsWith("scale") ? 1 : 0
347
432
  }
348
433
  return to
@@ -471,12 +556,50 @@ export default function initListeners() {
471
556
  },
472
557
  })
473
558
 
559
+ // Flex targets can't be split directly: the split parts would become
560
+ // flex ITEMS, so justify-content/gap would apply per letter, whitespace-
561
+ // only text nodes stop rendering (spaces vanish) and line grouping reads
562
+ // garbage. Loose text runs are therefore pre-wrapped in plain block
563
+ // divs — real boxes with normal inline flow inside — and the wrappers
564
+ // are undone whenever the split reverts. Non-text children (icons etc.)
565
+ // stay put, keeping their own flex-item status and the gaps around them.
566
+ const wrapFlexTarget = (el) => {
567
+ if (!/^(inline-)?flex$/.test(getComputedStyle(el).display)) return null
568
+ const wrappers = []
569
+ let run = []
570
+ const flush = () => {
571
+ if (!run.length) return
572
+ const w = document.createElement("div")
573
+ el.insertBefore(w, run[0])
574
+ run.forEach((n) => w.appendChild(n))
575
+ wrappers.push(w)
576
+ run = []
577
+ }
578
+ ;[...el.childNodes].forEach((n) => {
579
+ if (n.nodeType === 3 && n.textContent.trim()) run.push(n)
580
+ else flush()
581
+ })
582
+ flush()
583
+ if (!wrappers.length) return null
584
+ return () => wrappers.forEach((w) => {
585
+ while (w.firstChild) el.insertBefore(w.firstChild, w)
586
+ w.remove()
587
+ })
588
+ }
589
+
590
+ // Revert a SplitText instance AND undo any flex pre-wrapping made for
591
+ // it. Every revert path funnels through here so wrappers can't leak.
592
+ const revertSplitInstance = (s) => {
593
+ s.revert()
594
+ s._gcFlexUnwrap?.()
595
+ }
596
+
474
597
  const getSplit = (el, gran) => {
475
598
  let s = splitCache.get(el)
476
599
  const rtlChars = gran === "chars" && isRTLText(el)
477
600
  const key = rtlChars ? "rtl-chars" : gran
478
601
  if (!s || s.granularity !== key) {
479
- s?.revert()
602
+ if (s) revertSplitInstance(s)
480
603
  s = rtlChars
481
604
  ? getRTLCharSplit(el)
482
605
  : new SplitText(el, {
@@ -485,6 +608,7 @@ export default function initListeners() {
485
608
  wordsClass: "gsap-word",
486
609
  charsClass: "gsap-char",
487
610
  })
611
+ s._gcFlexUnwrap = wrapFlexTarget(el)
488
612
  s.granularity = key
489
613
  splitCache.set(el, s)
490
614
  textSplits.push(s)
@@ -514,7 +638,7 @@ export default function initListeners() {
514
638
  splitCache.delete(el)
515
639
  const idx = textSplits.indexOf(s)
516
640
  if (idx !== -1) textSplits.splice(idx, 1)
517
- s.revert()
641
+ revertSplitInstance(s)
518
642
  }
519
643
  // The whole point of `.time-X` on a `.spawn-text-X` element is that the
520
644
  // FULL reveal (first part starting to last part finishing) takes X
@@ -545,8 +669,17 @@ export default function initListeners() {
545
669
  duration = Math.min(dur, Math.max(dur / 3, defaults.minTextPartDuration))
546
670
  stagger = parts.length > 1 ? (dur - duration) / (parts.length - 1) : 0
547
671
  }
548
- return gsap.fromTo(parts, { ...from }, {
549
- ...computeTo(from), ease, duration, delay, stagger,
672
+ // .randomize-*: per-PART roll (function values evaluate once per
673
+ // target, so every char/word/line lands on its own pose); end
674
+ // states recompute from the merged keys so new props tween to rest.
675
+ let rnd = null
676
+ if (hasRandom(el)) {
677
+ rnd = randomVars(el)
678
+ if (!Object.keys(rnd).length) rnd = null
679
+ }
680
+ const effFrom = { ...from, ...rnd }
681
+ return gsap.fromTo(parts, effFrom, {
682
+ ...computeTo(effFrom), ease, duration, delay, stagger,
550
683
  onComplete: () => {
551
684
  if (el.classList.contains("leave")) refreshLeaveRect(el)
552
685
  revertSplit(el)
@@ -699,7 +832,17 @@ export default function initListeners() {
699
832
  scrollTriggers.push(t.scrollTrigger)
700
833
  }
701
834
  }
702
- gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
835
+ gsap.utils.toArray('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
836
+
837
+ // Scroller resolution: a `.scroll`/`.scroll-progress` element inside a
838
+ // `.scroll-frame` container binds its trigger to THAT box instead of the
839
+ // window. Innermost frame wins (`closest` walks up); no frame ancestor
840
+ // keeps the default window scroller. The frame must be a real scroller
841
+ // (fixed height + overflow auto/scroll) or its triggers never fire.
842
+ const getScroller = (el) => {
843
+ const frame = el.closest?.(".scroll-frame")
844
+ return frame && frame !== el ? frame : undefined
845
+ }
703
846
 
704
847
  // `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
705
848
  // Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
@@ -724,8 +867,9 @@ export default function initListeners() {
724
867
  for (const [key] of Object.entries(from)) {
725
868
  if (key === "opacity") to[key] = 1
726
869
  else if (key === "filter") to[key] = "blur(0px)"
727
- else if (key === "text") to[key] = el.innerHTML
870
+ else if (key === "text") to[key] = stashText(el)
728
871
  else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
872
+ else if (key === "drawSVG") to[key] = "100%"
729
873
  else to[key] = key.startsWith("scale") ? 1 : 0
730
874
  }
731
875
 
@@ -736,10 +880,16 @@ export default function initListeners() {
736
880
  // GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
737
881
  // the scrub maps in the opposite direction instead.
738
882
  const reverse = el.classList.contains("progress-reverse")
883
+ // .randomize-* applies to the HIDDEN start only (non-reverse):
884
+ // a scrub's resting end must stay deterministic or the element
885
+ // would sit permanently off-pose after being scrolled through.
886
+ const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
887
+ const rndEnds = rnd ? randomEnds(Object.keys(rnd)) : null
739
888
 
740
889
  const tl = gsap.timeline({
741
890
  scrollTrigger: {
742
891
  trigger: el,
892
+ scroller: getScroller(el),
743
893
  start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
744
894
  end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
745
895
  scrub: true,
@@ -751,11 +901,32 @@ export default function initListeners() {
751
901
  const { start, end, decimals } = countTargetVars(el)
752
902
  const obj = { n: reverse ? end : start }
753
903
  tl.fromTo(obj, { n: reverse ? end : start }, { n: reverse ? start : end, ease, onUpdate: () => { el.textContent = obj.n.toFixed(decimals) } }, 0)
904
+ } else if (config.scramble) {
905
+ // Scramble driven by scroll progress: each top-level text
906
+ // run scrubs through garbage states (.progress-reverse
907
+ // swaps the endpoints). The generic from/to scrub can't
908
+ // express this (it only knows the numeric `from` keys),
909
+ // which is why the entry carries the scramble flag.
910
+ // .scramble-all has no empty state: its tween runs between
911
+ // identical endpoints so the scrub just drives the sweep.
912
+ // Same ease rule as play(): linear unless .ease-* present.
913
+ const { segs, chars, speed, revealDelay, rtl } = scrambleVars(el)
914
+ const all = el.classList.contains("scramble-all")
915
+ segs.forEach(({ t, text }) => {
916
+ if (all) {
917
+ tl.to(t,
918
+ { scrambleText: { text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
919
+ } else {
920
+ tl.fromTo(t,
921
+ { scrambleText: { text: reverse ? text : "", chars } },
922
+ { scrambleText: { text: reverse ? "" : text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
923
+ }
924
+ })
754
925
  } else if (typewriterSplit) {
755
926
  const parts = getParts(el, getGranularity(el))
756
927
  if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
757
928
  } else {
758
- tl.fromTo(el, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
929
+ tl.fromTo(el, { ...(reverse ? to : from), ...rnd }, { ...rndEnds, ...(reverse ? from : to), ease })
759
930
  }
760
931
  scrollTriggers.push(tl.scrollTrigger)
761
932
  return
@@ -767,7 +938,7 @@ export default function initListeners() {
767
938
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
768
939
  : getEase(el)
769
940
 
770
- const fullText = el.innerHTML
941
+ const fullText = stashText(el)
771
942
 
772
943
  const enter = () => {
773
944
  if (el._scrollTween) el._scrollTween.kill()
@@ -775,7 +946,7 @@ export default function initListeners() {
775
946
  ? (typewriterSplit
776
947
  ? playTypewriterSplit(el, delay, duration, ease)
777
948
  : typewriter(el, fullText, duration, delay, ease))
778
- : play(el, delay, duration, ease)
949
+ : invokePlay(config, el, delay, duration, ease)
779
950
  el._scrollTween.eventCallback("onComplete", () => fireOnComplete(el, "spawn"))
780
951
  }
781
952
  const reverseToStart = () => {
@@ -802,6 +973,7 @@ export default function initListeners() {
802
973
 
803
974
  const st = ScrollTrigger.create({
804
975
  trigger: el,
976
+ scroller: getScroller(el),
805
977
  start: "top bottom",
806
978
  end: "bottom top",
807
979
  onEnter: enter,
@@ -834,6 +1006,7 @@ export default function initListeners() {
834
1006
  }
835
1007
  scrollTriggers.push(ScrollTrigger.create({
836
1008
  trigger: el,
1009
+ scroller: getScroller(el),
837
1010
  start: "top bottom",
838
1011
  end: "top top",
839
1012
  onEnter: enter,
@@ -855,14 +1028,19 @@ export default function initListeners() {
855
1028
  const tl = gsap.timeline({
856
1029
  scrollTrigger: {
857
1030
  trigger: el,
1031
+ scroller: getScroller(el),
858
1032
  start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
859
1033
  end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
860
1034
  scrub: true,
861
1035
  },
862
1036
  })
863
1037
  // `.progress-reverse` runs the split scrub in reverse; swap from/to.
1038
+ // Randomize applies to the hidden (non-reverse) start only, same
1039
+ // rule as the element-level scrub above.
864
1040
  const reverse = el.classList.contains("progress-reverse")
865
- tl.fromTo(parts, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
1041
+ const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
1042
+ tl.fromTo(parts, { ...(reverse ? to : from), ...rnd },
1043
+ { ...(rnd ? randomEnds(Object.keys(rnd)) : null), ...(reverse ? from : to), ease })
866
1044
  scrollTriggers.push(tl.scrollTrigger)
867
1045
  })
868
1046
  })
@@ -889,7 +1067,8 @@ export default function initListeners() {
889
1067
  setTimeout(scheduleRefresh, 400)
890
1068
  setTimeout(scheduleRefresh, 1200)
891
1069
 
892
- spawnConfigs.forEach(({ sel, typewriter: isTypewriter, typewriterSplit, play }) => {
1070
+ spawnConfigs.forEach((config) => {
1071
+ const { sel, typewriter: isTypewriter, typewriterSplit } = config
893
1072
  gsap.utils.toArray(sel).forEach((el) => {
894
1073
  if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
895
1074
  if (isPreserved(el)) return
@@ -902,10 +1081,10 @@ export default function initListeners() {
902
1081
  el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
903
1082
  } else {
904
1083
  el.typewriter?.kill()
905
- el.typewriter = typewriter(el, el.innerHTML, duration, delay, elEase)
1084
+ el.typewriter = typewriter(el, stashText(el), duration, delay, elEase)
906
1085
  }
907
1086
  } else {
908
- el._spawnTween = play(el, delay, duration, getEase(el))
1087
+ el._spawnTween = invokePlay(config, el, delay, duration, getEase(el))
909
1088
  el._spawnTween.eventCallback("onComplete", () => {
910
1089
  if (el.classList.contains("leave")) refreshLeaveRect(el)
911
1090
  if (el.classList.contains("flip")) captureFlip(el)
@@ -915,6 +1094,7 @@ export default function initListeners() {
915
1094
  })
916
1095
  }
917
1096
  markPreserved(el)
1097
+ el.dataset.gsapWired = "1"
918
1098
  })
919
1099
  })
920
1100
 
@@ -931,6 +1111,7 @@ export default function initListeners() {
931
1111
  const { delay, duration } = readTiming(el)
932
1112
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
933
1113
  markPreserved(el)
1114
+ el.dataset.gsapWired = "1"
934
1115
  })
935
1116
  })
936
1117
 
@@ -1077,8 +1258,11 @@ export default function initListeners() {
1077
1258
  el[key]?.kill()
1078
1259
  el[key] = trackCompatLoop(el, build(el, ctx))
1079
1260
  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.
1261
+ // Track wired loops so teardown can kill them (radiate
1262
+ // relies on kill/onInterrupt to remove its clones).
1263
+ if (!loopEls.some((l) => l.el === el && l.key === key)) {
1264
+ loopEls.push({ el, key })
1265
+ }
1082
1266
  el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
1083
1267
  () => fireOnComplete(el, "loop"))
1084
1268
  }
@@ -1361,9 +1545,9 @@ export default function initListeners() {
1361
1545
  const elEase = easeClass ? easeClass.split("-")[1] : "none"
1362
1546
  el._spawnTween = config.typewriterSplit
1363
1547
  ? playTypewriterSplit(el, delay, duration, elEase)
1364
- : config.play(el, delay, duration, elEase)
1548
+ : invokePlay(config, el, delay, duration, elEase)
1365
1549
  } else {
1366
- el._spawnTween = config.play(el, delay, duration, ease)
1550
+ el._spawnTween = invokePlay(config, el, delay, duration, ease)
1367
1551
  el._spawnTween.eventCallback("onComplete", () => {
1368
1552
  if (el.classList.contains("leave")) refreshLeaveRect(el)
1369
1553
  if (el.classList.contains("flip")) captureFlip(el)
@@ -1442,19 +1626,65 @@ export default function initListeners() {
1442
1626
  window.removeEventListener("load", ScrollTrigger.refresh)
1443
1627
  clearTimeout(refreshTimer)
1444
1628
  scrollTriggers.forEach((t) => {
1445
- t.kill()
1629
+ const tw = t.trigger._scrollTween
1630
+ // Finalize a mid-flight typewriter/scramble entrance at its end state
1631
+ // BEFORE killing: stranded partial/garbled text would otherwise be read as
1632
+ // settled content by the next run's stash.
1633
+ const cls = t.trigger.classList
1634
+ if (tw && !tw.reversed() && (cls?.contains("typewriter") || cls?.contains("scramble"))) tw.progress(1)
1635
+ // kill(true): revert pinning (remove pin-spacers, restore inline
1636
+ // styles) so a later init can re-pin cleanly instead of nesting a
1637
+ // second spacer inside the leaked first one.
1638
+ t.kill(true)
1446
1639
  t.trigger._scrollTween?.kill()
1447
1640
  delete t.trigger._scrollTween
1448
1641
  })
1449
1642
  ScrollTrigger.refresh()
1643
+ // Clear per-element wire-up tags. Without this, any engine restart
1644
+ // (initAnimations() called again, StrictMode remounts, route-level
1645
+ // re-mounts that keep DOM nodes alive) would silently skip rewiring:
1646
+ // .scroll/.pin/parallax triggers stay dead (their old ones were just
1647
+ // killed) and clicks/loops/hover never re-bind. `_appeared` goes too,
1648
+ // so dynamically-added .appear elements can animate under the new
1649
+ // engine run. data-gsap-preserved (cross-reset memory) and
1650
+ // data-gsap-ghost (detached .leave clones) are intentionally KEPT.
1651
+ gsap.utils.toArray('[data-gsap-scroll],[data-gsap-setup],[data-gsap-pinned],[data-gsap-scroll-driven]').forEach((el) => {
1652
+ delete el.dataset.gsapScroll
1653
+ delete el.dataset.gsapSetup
1654
+ delete el.dataset.gsapPinned
1655
+ delete el.dataset.gsapScrollDriven
1656
+ delete el._appeared
1657
+ })
1450
1658
  registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1451
1659
  magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1452
1660
  magnetQuery?.removeEventListener("change", applyMagnet)
1453
1661
  loopEls.forEach(({ el, key }) => el[key]?.kill())
1662
+ // Sweep any radiate clones orphaned by pre-fix kills or edge cases.
1663
+ document.querySelectorAll('[data-gsap-radiate]').forEach((n) => n.remove())
1454
1664
  cssTweens.forEach((t) => t?.kill())
1455
1665
  cssTweens.length = 0
1456
- gsap.utils.toArray(".typewriter").forEach(el => el.typewriter?.kill())
1457
- textSplits.forEach((s) => s.revert())
1666
+ gsap.utils.toArray(".typewriter, .scramble").forEach(el => {
1667
+ // Preserved-region typewriters/scrambles stay as-is (already at
1668
+ // their end state); finalize the rest so a mid-type/mid-scramble
1669
+ // kill can't strand partial or garbled text where the next run's
1670
+ // stash would read it.
1671
+ const keep = el.isConnected && underPreservedRoot(el)
1672
+ const tw = el.typewriter || el._spawnTween || el._scrollTween
1673
+ if (!keep && tw && !tw.reversed()) tw.progress(1)
1674
+ tw?.kill()
1675
+ })
1676
+ textSplits.forEach((s) => {
1677
+ // Splits inside a tagged preserve region keep their spans — the
1678
+ // next run will skip those elements, and reverting here would
1679
+ // visibly strip their finished animation. Everything else reverts
1680
+ // cleanly (and drops out of splitCache so a reused element can be
1681
+ // re-split fresh).
1682
+ const keep = (s.elements || []).some((e) => e.isConnected && underPreservedRoot(e))
1683
+ if (!keep) {
1684
+ ;(s.elements || []).forEach((e) => splitCache.delete(e))
1685
+ revertSplitInstance(s)
1686
+ }
1687
+ })
1458
1688
  textSplits.length = 0
1459
1689
  onCompleteTweens.forEach((t) => t?.kill())
1460
1690
  onCompleteTweens.length = 0
package/README.md CHANGED
@@ -4,6 +4,10 @@ A Tailwind-style utility layer on top of GSAP which I started developing for fun
4
4
 
5
5
  Framework-agnostic: works in vanilla JS, React, Vue, Svelte, or any bundler.
6
6
 
7
+ ## Live demo
8
+
9
+ https://saturn-sepehr.github.io/GClass/
10
+
7
11
  ## Installation
8
12
 
9
13
  ```
package/index.d.ts CHANGED
@@ -64,6 +64,10 @@ export interface AnimationConfig {
64
64
  typewriter?: boolean
65
65
  /** Marks a per-part (SplitText) typewriter entry. */
66
66
  typewriterSplit?: boolean
67
+ /** Marks a numeric counter entry (counts from `.spawn-num-N` to the element's number). */
68
+ count?: boolean
69
+ /** Marks a ScrambleText entry (text resolves out of garbage characters). */
70
+ scramble?: boolean
67
71
  /** Set false to skip generating a `.spawn-text-<name>` variant. Default true. */
68
72
  text?: boolean
69
73
  /** Set false to keep `build` but skip the always-on repeat. Default true. */
@@ -79,6 +83,8 @@ export interface SpawnConfig {
79
83
  play: (el: HTMLElement, delay: number, dur: number, ease: string) => any
80
84
  typewriter?: boolean
81
85
  typewriterSplit?: boolean
86
+ count?: boolean
87
+ scramble?: boolean
82
88
  text: boolean
83
89
  }
84
90
 
@@ -115,6 +121,8 @@ export interface Defaults {
115
121
  textStagger: number
116
122
  typewriterSplitCharDuration: number
117
123
  minTextPartDuration: number
124
+ revealDelay: number
125
+ characterlist: string
118
126
  }
119
127
 
120
128
  /** Global timing / ease defaults (edit to tweak global behaviour). */
@@ -162,6 +170,40 @@ export function expandDown(target: TweenTarget, delay: number, dur: number, ease
162
170
  export function countUp(target: TweenTarget, delay: number, dur: number, ease: string): any
163
171
  /** Reads a count element's start (`.spawn-num-N`, else 0), target number, and decimals. */
164
172
  export function countTargetVars(target: TweenTarget): { start: number; end: number; decimals: number }
173
+ /**
174
+ * Reads a scramble element's modifiers against the package defaults:
175
+ * `.amount-N` -> speed (default 1), `.reveal-delay-N` -> revealDelay
176
+ * (default `defaults.revealDelay`), `.chars-[...]` -> character pool verbatim
177
+ * (default `defaults.characterlist`). Also segments the element: only
178
+ * top-level text runs are scrambled (each wrapped in its own span); nested
179
+ * elements are preserved untouched.
180
+ */
181
+ export function scrambleVars(target: TweenTarget): {
182
+ segs: { t: any; text: string }[]
183
+ chars: string
184
+ speed: number
185
+ revealDelay: number
186
+ rtl: boolean
187
+ }
188
+ /**
189
+ * Scramble spawn: the text starts empty and resolves into its real content
190
+ * through garbage characters (ScrambleTextPlugin). No opacity change; nested
191
+ * elements are preserved. Defaults to a linear ease so `.time-N` is the true
192
+ * total reveal time — an explicit `.ease-*` class overrides. Modifiers read
193
+ * from the element: .reveal-delay-N, .chars-[...], .amount-N, .scramble-all
194
+ * (whole-string scramble-and-sweep, no empty-start typing), .scramble-rtl.
195
+ */
196
+ export function scramble(target: TweenTarget, delay: number, dur: number, ease: string): any
197
+ /** Progressively draws the target's SVG stroke from 0% to 100% (strokes only). */
198
+ export function drawsvg(target: TweenTarget, delay: number, dur: number, ease: string): any
199
+ /**
200
+ * Splits multi-segment `<path>` elements (multiple "M" commands) into one
201
+ * single-segment `<path>` per segment. Destructive: source paths are replaced.
202
+ * Results are cached on the original element for engine re-inits.
203
+ */
204
+ export function splitPaths(paths: any): any[]
205
+ /** DrawSVG variant that splits multi-segment paths first, then draws each segment sequentially at constant speed. Returns a timeline. */
206
+ export function drawsvgSplit(target: TweenTarget, delay: number, dur: number, ease: string): any
165
207
  export function verticalmove(target: TweenTarget, amount: number, dur: number, ease: string): any
166
208
  export function expandmove(target: TweenTarget, amount: number, dur: number, ease: string): any
167
209
  export function magnet(target: TweenTarget, x: number, y: number, scale: number, dur: number, ease: string): any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gclass-anims",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.11",
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",
@@ -22,7 +22,8 @@
22
22
  "Config.js",
23
23
  "CustomAnims.js",
24
24
  "README.md",
25
- "LICENSE"
25
+ "LICENSE",
26
+ "CHANGELOG.md"
26
27
  ],
27
28
  "keywords": [
28
29
  "gsap",
@@ -39,7 +40,7 @@
39
40
  "url": "git+https://github.com/Saturn-sepehr/GClass.git",
40
41
  "directory": "/"
41
42
  },
42
- "homepage": "https://github.com/Saturn-sepehr/GClass#readme",
43
+ "homepage": "https://saturn-sepehr.github.io/GClass/",
43
44
  "dependencies": {
44
45
  "gsap": "^3.15.0"
45
46
  },