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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -58,7 +79,11 @@ export function expandA (target , delay , dur , ease){
58
79
  }
59
80
 
60
81
  export function typewriter (target , text , dur , delay , ease){
61
- return gsap.fromTo(target , {text:""} , {ease:easeOf(ease) , duration:dur , delay:delay , text:text})
82
+ // Any element using TextPlugin gets pre-wrap so "\n" from VSCode Enter or JS strings
83
+ // renders as a real line break and any innerHTML formatting is preserved.
84
+ gsap.utils.toArray(target).forEach(el => { if (el.style) el.style.whiteSpace = "pre-wrap" })
85
+ const value = String(text).replace(/\n/g, "<br>")
86
+ return gsap.fromTo(target , {text:""} , {ease:easeOf(ease) , duration:dur , delay:delay , text:{value}})
62
87
  }
63
88
 
64
89
  export function spawnSpinCCW (target , delay , dur , ease){
@@ -90,7 +115,7 @@ export function spawnBlur (target , delay , dur , ease){
90
115
  // right - hidden on the left, wipes open rightward (left -> right)
91
116
  // The `from` inset is mirrored in the Config entry so
92
117
  // `.scroll`/`.scroll-progress`/`.leave` reversal and `.appear` all know the
93
- // hidden state. No opacity is involved pure clip wipe.
118
+ // hidden state. No opacity is involved - pure clip wipe.
94
119
  const CLIP_FROM = {
95
120
  up: "inset(0% 0% 100% 0%)",
96
121
  down: "inset(100% 0% 0% 0%)",
@@ -102,13 +127,13 @@ export function spawnClipReveal (target , delay , dur , ease , dir = "up"){
102
127
  return gsap.fromTo(target , {clipPath: from} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
103
128
  }
104
129
 
105
- // Curtain reveal: opens outward from the horizontal centre a vertical slit in
130
+ // Curtain reveal: opens outward from the horizontal centre - a vertical slit in
106
131
  // the middle widens left and right until the whole box is shown.
107
132
  export function curtainHorizontal (target , delay , dur , ease){
108
133
  return gsap.fromTo(target , {clipPath:"inset(0% 50% 0% 50%)"} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
109
134
  }
110
135
 
111
- // Curtain reveal: opens outward from the vertical centre a horizontal slit in
136
+ // Curtain reveal: opens outward from the vertical centre - a horizontal slit in
112
137
  // the middle widens up and down until the whole box is shown.
113
138
  export function curtainVertical (target , delay , dur , ease){
114
139
  return gsap.fromTo(target , {clipPath:"inset(50% 0% 50% 0%)"} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
@@ -201,6 +226,227 @@ export function countUp (target , delay , dur, ease){
201
226
  return gsap.timeline({ delay }).fromTo(obj , { n: start } , { n: end , duration:dur , ease:e , onUpdate: () => { target.textContent = obj.n.toFixed(decimals) } } , 0)
202
227
  }
203
228
 
229
+ // Helpers for the `fill-svg` draw-modifier. When an element carries both
230
+ // `.draw`/`.draw-split` and `.fill-svg`, the stroke is drawn first and then
231
+ // the interior fills. `fill-time-N` / `fill-ease-NAME` override the fill
232
+ // phase; otherwise the fill takes half of `dur` and reuses the draw ease.
233
+ const fillTimeOf = (el , fallback) => {
234
+ const m = [...el.classList].find(c => c.startsWith("fill-time-"))
235
+ return m ? Number(m.slice("fill-time-".length)) : fallback * 0.5
236
+ }
237
+ const fillEaseOf = (el , fallbackEase) => {
238
+ const m = [...el.classList].find(c => c.startsWith("fill-ease-"))
239
+ return m ? m.slice("fill-ease-".length) : fallbackEase
240
+ }
241
+
242
+ // Stroke-draw reveal (strokes only - filled SVGs are deliberately out of
243
+ // scope for now). Explicit fromTo endpoints so the animation's hidden state
244
+ // matches this class's Config `from` metadata exactly: `.scroll-progress`
245
+ // scrubs between those two values and `.leave`/`.scroll` reversal tweens back
246
+ // into them. When the target also carries `.fill-svg`, the interior is filled
247
+ // after the stroke finishes (draw → fill sequential timeline).
248
+ export function drawsvg (target , delay , dur , ease){
249
+ const e = easeOf(ease)
250
+ const first = gsap.utils.toArray(target)[0]
251
+ const hasFill = !!first?.classList?.contains("fill-svg")
252
+ if (!hasFill) {
253
+ return gsap.fromTo(target , {drawSVG:"0%"} , {ease:e , duration:dur , delay:delay , drawSVG:"100%"})
254
+ }
255
+ const fillDur = fillTimeOf(first , dur)
256
+ const fillEase = fillEaseOf(first , ease)
257
+ const fillTargets = gsap.utils.toArray(target).filter(el => el.classList.contains("fill-svg"))
258
+ const tl = gsap.timeline({ delay })
259
+ // Keep fill invisible while the stroke draws
260
+ if (fillTargets.length) gsap.set(fillTargets , { fillOpacity: 0 })
261
+ tl.fromTo(target , {drawSVG:"0%"} , {ease:e , duration:dur , drawSVG:"100%"})
262
+ if (fillTargets.length) {
263
+ tl.fromTo(fillTargets , {fillOpacity:0} , {ease:easeOf(fillEase) , duration:fillDur , fillOpacity:1})
264
+ }
265
+ return tl
266
+ }
267
+
268
+ // Busts a multi-segment <path> (one containing multiple "M" commands) apart
269
+ // into one single-segment <path> per segment. Browsers can't reliably render
270
+ // a stroke-dash progressive reveal across disconnected subpaths, while
271
+ // separate paths draw correctly. Adapted from the official DrawSVGPlugin
272
+ // helper, with one addition: splitting REPLACES the source path in the DOM,
273
+ // so the result is cached on that element and reused while the segments are
274
+ // still live - an engine re-init (StrictMode remount, route change) must not
275
+ // churn the DOM a second time. Attributes are copied verbatim; filled SVGs
276
+ // are simply untouched territory for now.
277
+ export function splitPaths (paths){
278
+ const toSplit = gsap.utils.toArray(paths)
279
+ let newPaths = []
280
+ if (toSplit.length > 1) {
281
+ toSplit.forEach(path => newPaths.push(...splitPaths(path)))
282
+ return newPaths
283
+ }
284
+ const path = toSplit[0]
285
+ if (!path) return newPaths
286
+ if (path._gcSplitPaths?.[0]?.isConnected) return path._gcSplitPaths
287
+ const rawPath = MotionPathPlugin.getRawPath(path)
288
+ const parent = path.parentNode
289
+ const attributes = [...path.attributes]
290
+ newPaths = rawPath.map(segment => {
291
+ const newPath = document.createElementNS("http://www.w3.org/2000/svg" , "path")
292
+ let i = attributes.length
293
+ while (i--) {
294
+ const attr = attributes[i]
295
+ // Don't copy GSAP wiring or appear/scroll triggers - children are
296
+ // animated via the returned timeline, not as independent spawns.
297
+ // Copying "appear" caused appearObserver → split → appear loop.
298
+ if (attr.nodeName === "class") {
299
+ const filtered = attr.nodeValue
300
+ .split(/\s+/)
301
+ .filter(c => c && c !== "appear" && c !== "scroll" && c !== "scroll-progress" && c !== "draw" && c !== "draw-split")
302
+ .join(" ")
303
+ if (filtered) newPath.setAttributeNS(null, "class", filtered)
304
+ continue
305
+ }
306
+ if (attr.nodeName.startsWith("data-gsap")) continue
307
+ newPath.setAttributeNS(null , attr.nodeName , attr.nodeValue)
308
+ }
309
+ newPath.setAttributeNS(null , "d" ,
310
+ "M" + segment[0] + "," + segment[1] +
311
+ "C" + segment.slice(2).join(",") +
312
+ (segment.closed ? "z" : ""))
313
+ // Isolate paint and mark as split child so future inits skip it
314
+ newPath.dataset.gsapSplit = "1"
315
+ newPath.style.contain = "paint"
316
+ newPath.style.willChange = "transform"
317
+ parent.insertBefore(newPath , path)
318
+ return newPath
319
+ })
320
+ parent.removeChild(path)
321
+ return path._gcSplitPaths = newPaths
322
+ }
323
+
324
+ // Like drawsvg but built for MULTI-SEGMENT paths: splitPaths() first, then
325
+ // draw each resulting segment one after another, giving every segment a slice
326
+ // of `dur` proportional to its own stroke length so the pen travels at a
327
+ // constant speed across the whole drawing. Returns a timeline, so leave /
328
+ // scroll reversal un-draws the segments back-to-front and the engine's
329
+ // onComplete hooks fire only after the final segment lands. When the source
330
+ // also carries `.fill-svg`, every resulting segment is filled together after
331
+ // the last draw segment lands (draw → fill).
332
+ export function drawsvgSplit (target , delay , dur , ease){
333
+ const e = easeOf(ease)
334
+ const first = gsap.utils.toArray(target)[0]
335
+ const hasFill = !!first?.classList?.contains("fill-svg")
336
+ const fillDur = hasFill ? fillTimeOf(first , dur) : 0
337
+ const fillEase = hasFill ? fillEaseOf(first , ease) : ease
338
+ const tl = gsap.timeline({ delay })
339
+ const paths = splitPaths(target)
340
+ let distance = 0
341
+ paths.forEach(segment => distance += segment.getTotalLength())
342
+ // Nothing drawable (empty selection / zero-length strokes): hand back the
343
+ // inert timeline rather than divide by zero below.
344
+ if (!distance) return tl
345
+ if (hasFill && paths.length) gsap.set(paths , { fillOpacity: 0 })
346
+ paths.forEach(segment => {
347
+ tl.fromTo(segment ,
348
+ {drawSVG:"0%"} ,
349
+ {ease:e , duration:dur * (segment.getTotalLength() / distance) , drawSVG:"100%"})
350
+ })
351
+ if (hasFill && paths.length) {
352
+ tl.fromTo(paths , {fillOpacity:0} , {ease:easeOf(fillEase) , duration:fillDur , fillOpacity:1})
353
+ }
354
+ return tl
355
+ }
356
+
357
+ // Scramble plumbing. Only the element's TOP-LEVEL TEXT runs are scrambled:
358
+ // each run is wrapped in its own span and tweened separately, while real child
359
+ // elements (links, icons, ...) are left completely untouched - their markup
360
+ // survives the animation intact. Wraps are cached on the element so replays
361
+ // (engine re-inits, .appear re-triggers) reuse the same spans instead of
362
+ // churning the DOM.
363
+ export const scrambleSegments = (target) => {
364
+ let wraps = target._gcScrambleSegs
365
+ if (!wraps || !wraps.length || !wraps.every((w) => w.parentNode === target)) {
366
+ wraps = []
367
+ ;[...target.childNodes].forEach((node) => {
368
+ // Whitespace-only runs stay bare so natural spacing is preserved;
369
+ // everything else becomes an individually scrambable span.
370
+ if (node.nodeType !== 3 || !node.textContent.trim()) return
371
+ // ScrambleTextPlugin TRIMS its targets, so a span holding
372
+ // " with a " would resolve to "with a" and swallow the spaces
373
+ // around a neighbouring element. Split the edge whitespace off
374
+ // into bare text nodes and wrap only the trimmed core.
375
+ const raw = node.textContent
376
+ const core = raw.trim()
377
+ const leadIdx = raw.indexOf(core[0])
378
+ const trailStart = leadIdx + core.length
379
+ const frag = document.createDocumentFragment()
380
+ if (leadIdx > 0) frag.appendChild(document.createTextNode(raw.slice(0 , leadIdx)))
381
+ const span = document.createElement("span")
382
+ span.textContent = core
383
+ frag.appendChild(span)
384
+ if (trailStart < raw.length) frag.appendChild(document.createTextNode(raw.slice(trailStart)))
385
+ target.insertBefore(frag , node)
386
+ target.removeChild(node)
387
+ wraps.push(span)
388
+ })
389
+ target._gcScrambleSegs = wraps
390
+ }
391
+ return wraps.map((w) => ({ t: w , text: w.textContent }))
392
+ }
393
+
394
+ // Reads a scramble element's modifier classes and resolves them against the
395
+ // package defaults:
396
+ // .amount-N -> ScrambleText speed (default 1, GSAP's own default)
397
+ // .reveal-delay-N -> revealDelay in seconds (default defaults.revealDelay)
398
+ // .chars-[...] -> character pool taken verbatim from inside the brackets
399
+ // (default defaults.characterlist)
400
+ export function scrambleVars (target){
401
+ const num = (prefix , fallback) => {
402
+ const match = [...target.classList].find(c => c.startsWith(prefix))
403
+ return match ? Number(match.slice(prefix.length)) : fallback
404
+ }
405
+ // Greedy up to the LAST "]" so pools containing "]" survive intact.
406
+ const charsCls = [...target.classList].find(c => /^chars-\[(.*)\]$/.test(c))
407
+ return {
408
+ segs: scrambleSegments(target) ,
409
+ chars: charsCls ? charsCls.slice("chars-[".length , -1) : defaults.characterlist ,
410
+ speed: num("amount-" , 1) ,
411
+ revealDelay: num("reveal-delay-" , defaults.revealDelay) ,
412
+ // .scramble-rtl flips the reveal direction (ScrambleTextPlugin's
413
+ // rightToLeft) so the sweep travels right -> left.
414
+ rtl: target.classList.contains("scramble-rtl") ,
415
+ }
416
+ }
417
+
418
+ // Scramble spawn: the text starts empty and resolves into the real content
419
+ // through garbage characters - no opacity involved, the scramble IS the
420
+ // reveal. Unlike typewriter there is no opacity fade to hide behind, so the
421
+ // package default "back" ease would visually finish at ~36% of `dur` (back.out
422
+ // crosses ~99% early and the reveal index clamps): unless an explicit ease-*
423
+ // class is present the tween therefore eases linearly, making time-N the TRUE
424
+ // total reveal time. One timeline holds a per-text-run tween at position 0 so
425
+ // leave/scroll reversal and onComplete hooks treat it as a single animation.
426
+ //
427
+ // Variants / modifiers:
428
+ // .scramble-all - no empty-start typing: the already-finished string
429
+ // flips to garbage as a whole and sweeps back (native
430
+ // ScrambleTextPlugin resolve).
431
+ // .scramble-rtl - reveal travels right -> left.
432
+ export function scramble (target , delay , dur , ease){
433
+ const e = [...target.classList].some(c => c.startsWith("ease-")) ? easeOf(ease) : "none"
434
+ const { segs , chars , speed , revealDelay , rtl } = scrambleVars(target)
435
+ const all = target.classList.contains("scramble-all")
436
+ const tl = gsap.timeline({ delay })
437
+ segs.forEach(({ t , text }) => {
438
+ if (all) {
439
+ tl.to(t ,
440
+ {scrambleText:{text , chars , speed , revealDelay , rightToLeft:rtl} , ease:e , duration:dur} , 0)
441
+ } else {
442
+ tl.fromTo(t ,
443
+ {scrambleText:{text:"" , chars}} ,
444
+ {scrambleText:{text , chars , speed , revealDelay , rightToLeft:rtl} , ease:e , duration:dur} , 0)
445
+ }
446
+ })
447
+ return tl
448
+ }
449
+
204
450
 
205
451
  //Mouse animations
206
452
 
@@ -295,7 +541,7 @@ export function shake (delay , target , amount , dur , ease){
295
541
  export function bell (delay , target , amount , dur , ease){
296
542
  const tl = gsap.timeline()
297
543
  tl.set(target , {transformOrigin : "50% 0%"})
298
- // A quick toll that overshoots and damps down reads as a physical strike
544
+ // A quick toll that overshoots and damps down - reads as a physical strike
299
545
  // instead of a symmetrical wiggle.
300
546
  .to(target , {rotate:amount , duration:dur * 0.12 , ease:"power2.out"})
301
547
  .to(target , {rotate:-amount * 0.7, duration:dur * 0.18 , ease:"power2.inOut"})
@@ -323,6 +569,9 @@ export function pulse (delay , target , amount , dur , ease){
323
569
 
324
570
  export function radiate (delay , target , amount , dur , ease , zIndex){
325
571
  const clone = target.cloneNode(true)
572
+ // Tagged so engine teardown can sweep up clones whose tween was killed
573
+ // before its onComplete (route changes mid-animation).
574
+ clone.setAttribute("data-gsap-radiate", "1")
326
575
  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
576
  // Keep the ripple glued to the target so it tracks scroll/resize instead of
328
577
  // getting stranded at the position captured when the animation was built.
@@ -342,23 +591,43 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
342
591
  tick = true
343
592
  requestAnimationFrame(() => { tick = false; applyRect() })
344
593
  }
594
+ // Don't animate detached targets - return inert tween
595
+ if (!target.isConnected) {
596
+ return gsap.fromTo(clone, {}, { duration: 0 })
597
+ }
345
598
  document.body.appendChild(clone)
346
599
  applyRect()
347
600
  window.addEventListener("scroll", schedule, { passive: true })
348
601
  window.addEventListener("resize", schedule, { passive: true })
602
+ // Killing the tween (teardown, hover/click rebuilds) must clean up exactly
603
+ // like natural completion - otherwise clones + listeners leak.
604
+ let tween = null
605
+ const cleanup = () => {
606
+ clone.remove()
607
+ window.removeEventListener("scroll", schedule)
608
+ window.removeEventListener("resize", schedule)
609
+ if (observer) observer.disconnect()
610
+ }
611
+ // If target is removed from DOM (React unmount, .remove(), SPA navigation),
612
+ // kill the tween and remove the clone - mirrors React useEffect cleanup
613
+ const observer = new MutationObserver(() => {
614
+ if (!target.isConnected) {
615
+ if (tween) tween.kill()
616
+ cleanup()
617
+ }
618
+ })
619
+ observer.observe(document.body, { childList: true, subtree: true })
349
620
 
350
- return gsap.fromTo(clone , {scale:1 , opacity:1} , {
621
+ tween = gsap.fromTo(clone , {scale:1 , opacity:1} , {
351
622
  scale:amount / 10 ,
352
623
  opacity:0 ,
353
624
  duration:dur ,
354
625
  delay:delay ,
355
626
  ease:easeOf(ease) ,
356
- onComplete: () => {
357
- clone.remove()
358
- window.removeEventListener("scroll", schedule)
359
- window.removeEventListener("resize", schedule)
360
- } ,
627
+ onComplete: () => { observer.disconnect(); cleanup(); },
628
+ onInterrupt: () => { observer.disconnect(); cleanup(); },
361
629
  })
630
+ return tween
362
631
  }
363
632
 
364
633
 
@@ -378,33 +647,57 @@ export function marquee (target , dir , duration , xOffset = 0 , yOffset = 0 , n
378
647
  const horizontal = dir === "left" || dir === "right"
379
648
  // Anchor the track to the top-left corner so its two identical copies tile
380
649
  // the container exactly. The track is positioned absolutely, out of the
381
- // container's flex layout otherwise a `justify-center` (or any alignment)
650
+ // container's flex layout - otherwise a `justify-center` (or any alignment)
382
651
  // on the container centers the overflowing track and shifts the tile seam,
383
652
  // which opens a gap on the trailing edge at some point in the loop.
384
653
  target.style.position = "relative"
385
654
  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.
655
+
656
+ // Rebuilds over the SAME element (engine restarts, StrictMode remounts)
657
+ // must reuse the existing track. Re-creating it would swallow the old
658
+ // absolute track as the first "child", repeat THAT as the tiling unit -
659
+ // every copy stacks at the same offset and scrollWidth collapses.
660
+ let track = target._gcTrack
661
+ if (!track || !track.isConnected) {
662
+ track = document.createElement("div")
663
+ track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
664
+ while (target.firstChild) track.appendChild(target.firstChild)
665
+ target.appendChild(track)
666
+ // Guard against legacy poisoned DOM: older builds could wrap a previous
667
+ // absolute track inside this one. Unwrap any nested engine tracks so
668
+ // the stashed unit is always the raw content.
669
+ while (
670
+ track.children.length &&
671
+ [...track.children].every((c) => c.style.position === "absolute" && c.style.display === "flex")
672
+ ) {
673
+ const inner = track.firstElementChild
674
+ while (inner.firstChild) track.insertBefore(inner.firstChild, inner)
675
+ track.removeChild(inner)
676
+ }
677
+ target._gcUnitHtml = track.innerHTML
678
+ }
679
+ track.style.flexDirection = horizontal ? "row" : "column"
680
+ const unitHtml = target._gcUnitHtml
681
+
682
+ // Measure ONE unit on its own: the live track may already hold N copies
683
+ // (or stale content), which would inflate unitSize and starve `copies`.
684
+ const measurer = document.createElement("div")
685
+ measurer.style.cssText = track.style.cssText + "visibility:hidden;"
686
+ measurer.innerHTML = unitHtml
687
+ target.appendChild(measurer)
688
+ const unitSize = horizontal ? measurer.scrollWidth : measurer.scrollHeight
689
+ measurer.remove()
690
+
691
+ // Nothing to tile (empty content) - return an inert tween rather than one
692
+ // dividing by a zero-width unit.
693
+ if (!unitSize) return gsap.fromTo(track, {}, { duration: 0 })
694
+
695
+ // The track is absolutely positioned, so a bare-text host can collapse to
696
+ // zero height and `overflow:hidden` would clip the strip away entirely.
397
697
  if (target.offsetHeight === 0 && track.offsetHeight > 0) {
398
698
  target.style.height = track.offsetHeight + "px"
399
699
  }
400
700
 
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
701
  // Default: repeat the unit until the whole strip is at least as wide as the
409
702
  // viewport (plus one extra copy so the trailing edge stays covered mid-loop).
410
703
  // `.marquee-no-repeat` opts into the minimal 2-copy single-seam behaviour.
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to `gclass-anims` will be documented in this file.
4
+
5
+ ## [1.0.0-beta.21] - 2026-9-3
6
+ - Added a `gclassOpts()` function that controls the animation fps and observer throttling
7
+
8
+ ## [1.0.0-beta.20] - 2026-9-2
9
+ - Fix ESM strict import: `AnimToggle.js` and `Listeners.js` now use `.js` extensions (`Remix`/`Qwik` Node ESM `Cannot find module` fix)
10
+ - Fix `Lit` shadow DOM `works.txt` `no` → light DOM default (`createRenderRoot(){return this}`) + `initListeners(shadowRoot)` docs
11
+ - Fix `SvelteKit` `ERESOLVE` (`@sveltejs/vite-plugin-svelte 4` → `5.1` for `vite@6`) + missing `src/app.html`
12
+ - Fix `Qwik` `entry.ssr not found` → add `src/root.tsx`+`entry.ssr.tsx`+`tsconfig.json`
13
+ - Fix `SolidStart` Vinxi `503` → simplified to `vite-plugin-solid` SPA (same fine-grained model)
14
+ - Reverted license `LGPL-3.0-only` → `MIT` (anywhere GSAP is usable)
15
+
16
+ ## [1.0.0-beta.19] - 2026-9-1
17
+ - Fixed the SplitText animations formatting and removed Boot.js
18
+
19
+ ## [1.0.0-beta.18] - 2026-9-1
20
+ - Hopefully finally fixed `.boot-up` properly skipping on path changes
21
+
22
+ ## [1.0.0-beta.17] - 2026-9-1
23
+ - Fixed the `.boot-up` class firing on every path change
24
+
25
+ ## [1.0.0-beta.16] - 2026-09-1
26
+ - Added a new `.boot-up` class for boot up animations
27
+ - Fixed text animations not taking formatting into account
28
+
29
+
30
+ ## [1.0.0-beta.13] - 2026-08-27
31
+ - Added a new `.fill-svg` modifier for the `.draw` and `.draw-split` classes that fills the SVG after it has been drawn.
32
+
33
+ ## [1.0.0-beta.12] - 2026-08-26
34
+
35
+ - Fixed `scramble` with `scroll-progress` throwing `can't convert undefined to object` - `computeTo` (`Listeners.js:424`) and scrub `to` builder (`Listeners.js:867`) now guard `from` (`scramble` has no `from`).
36
+
37
+ ## [1.0.0-beta.11] - 2026-08-26
38
+
39
+ - 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.
40
+
41
+ ## [1.0.0-beta.10] - 2026-08-26
42
+
43
+ - 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`).
44
+ - Added `.draw` - stroke-draw reveal for SVG paths using DrawSVGPlugin (`drawSVG: 0% → 100%`).
45
+ - Added `.draw-split` - draws multi-segment SVG paths sequentially at constant pen speed (splits paths with multiple `M` commands into individual strokes).
46
+ - Added `.scramble` - text resolves from empty through scrambled characters into real content (ScrambleTextPlugin). Supports `.reveal-delay-N`, `.chars-[...]`, `.amount-N`, `.scramble-rtl`.
47
+ - Added `.scramble-all` - variant of scramble with no empty start; the finished string flips to garbage as a whole then sweeps back.
48
+ - Added `.scroll-frame` - use a scrollable container as the ScrollTrigger scroller for nested `.scroll` / `.scroll-progress` elements (innermost `.scroll-frame` ancestor wins).
49
+ - 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.
50
+
51
+ ## [1.0.0-beta.9] - Previous release
52
+
53
+ - See git history for earlier changes.
package/Config.js CHANGED
@@ -3,15 +3,16 @@ 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
  // ---------------------------------------------------------------------------
10
- // GClass configuration THE single place to add / remove / tweak animations.
11
+ // GClass configuration - THE single place to add / remove / tweak animations.
11
12
  //
12
13
  // `animations` is an array of entries. Each entry is a plain object; the engine
13
14
  // inspects which fields are present and wires up the matching behaviour
14
- // automatically no engine edits needed:
15
+ // automatically - no engine edits needed:
15
16
  //
16
17
  // sel - the className you put on elements (e.g. ".spawn-up")
17
18
  //
@@ -68,6 +69,9 @@ export const defaults = {
68
69
  textStagger: 0.03,
69
70
  typewriterSplitCharDuration: 0.05,
70
71
  minTextPartDuration: 0.3,
72
+ revealDelay:0,
73
+ characterlist:"AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz",
74
+ bootTime: 5,
71
75
  }
72
76
 
73
77
  export const animations = [
@@ -91,13 +95,32 @@ export const animations = [
91
95
  { sel: ".expand-up", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandUp(el, delay, dur, ease) },
92
96
  { sel: ".expand-down", from: { opacity: 0, scaleY: 0 }, play: (el, delay, dur, ease) => expandDown(el, delay, dur, ease) },
93
97
  { 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) },
98
+ { sel: ".typewriter", typewriter: true, from: { text: "" }, play: (el, delay, dur, ease) => typewriter(el, stashText(el), dur, delay, ease) },
95
99
  { sel: ".typewriter-split", typewriter: true, typewriterSplit: true, from: { opacity: 0 }, play: (el, delay, dur, ease) => null },
96
100
 
101
+ // Scramble reveal: the text resolves out of garbage characters (ScrambleText).
102
+ // No opacity change - the scramble IS the spawn. Only the element's own text
103
+ // runs animate; nested elements (links, icons) are preserved untouched.
104
+ // Deliberately NO `from`: the scramble manages its own DOM (segment spans),
105
+ // so the generic TextPlugin-based reversal would destroy it - `.scroll`
106
+ // exit simply freezes the revealed state and re-entry replays fresh.
107
+ // `scramble: true` gives `.scroll-progress` a true scrub branch (like
108
+ // `.count`), since the generic from/to scrub can't express a text tween.
109
+ // Defaults to a LINEAR ease so time-N is the true total reveal time;
110
+ // an explicit .ease-* class overrides. Modifiers:
111
+ // .reveal-delay-N - seconds of full-garbage hold before chars start locking in
112
+ // .chars-[...] - the character pool, verbatim inside the brackets
113
+ // .amount-N - scramble speed (ScrambleTextPlugin `speed`)
114
+ // .scramble-all - no typing: the finished string flips to garbage as a
115
+ // whole and sweeps back (native plugin resolve)
116
+ // .scramble-rtl - reveal travels right -> left
117
+ { sel: ".scramble", scramble: true, text: false, play: (el, delay, dur, ease) => scramble(el, delay, dur, ease) },
118
+ { sel: ".scramble-all", scramble: true, text: false, play: (el, delay, dur, ease) => scramble(el, delay, dur, ease) },
119
+
97
120
  // Custom-function animation: counts from the `.spawn-num-N` value (N = the
98
121
  // starting number) up to whatever number is in the element (falling back to 0
99
122
  // when no `.spawn-num-N` class is present). `play` just wraps a helper from
100
- // Animations.js nothing else is special, so it still gets
123
+ // Animations.js - nothing else is special, so it still gets
101
124
  // order/scroll/leave/appear automatically.
102
125
  { sel: ".count", count: true, text: false, from: { opacity: 0 }, play: (el, delay, dur, ease) => countUp(el, delay, dur, ease) },
103
126
 
@@ -114,6 +137,20 @@ export const animations = [
114
137
  { sel: ".curtain-horizontal", text: false, from: { clipPath: "inset(0% 50% 0% 50%)" }, play: (el, delay, dur, ease) => curtainHorizontal(el, delay, dur, ease) },
115
138
  { sel: ".curtain-vertical", text: false, from: { clipPath: "inset(50% 0% 50% 0%)" }, play: (el, delay, dur, ease) => curtainVertical(el, delay, dur, ease) },
116
139
 
140
+ // Stroke-draw reveals (strokes only - filled SVGs are a separate plan).
141
+ // The hidden state is a fully undrawn stroke (`drawSVG: "0%"`): that's what
142
+ // `.scroll-progress` scrubs up from and what leave/scroll reversal returns
143
+ // to. `.draw` animates its target(s) as one stroke; `.draw-split` first
144
+ // splits multi-segment paths (paths with multiple "M" commands) into one
145
+ // <path> per segment and draws them sequentially at constant pen speed.
146
+ // `.fill-svg` is a MODIFIER for both: add it alongside `.draw`/`.draw-split`
147
+ // to fill the interior after the stroke finishes (draw → fill). Tunables:
148
+ // `fill-time-N` (fill duration, default = 0.5× draw time) and
149
+ // `fill-ease-NAME` (default reuses draw ease). Requires a fill color on
150
+ // the element (`fill` attribute or CSS); the modifier animates `fillOpacity`.
151
+ { sel: ".draw", text: false, from: { drawSVG: "0%" }, play: (el, delay, dur, ease) => drawsvg(el, delay, dur, ease) },
152
+ { sel: ".draw-split", text: false, from: { drawSVG: "0%" }, play: (el, delay, dur, ease) => drawsvgSplit(el, delay, dur, ease) },
153
+
117
154
 
118
155
  // --- Loops (build + key). Also usable via hover-<name>/click-<name> ---------
119
156
  { sel: ".shake", build: (el, { edelay, amount, etime, ease }) => shake(edelay, el, amount, etime, ease), key: "shakeanim" },
@@ -147,6 +184,7 @@ export function normalize(extra = []) {
147
184
  typewriterSplit: a.typewriterSplit,
148
185
  text: a.text !== false,
149
186
  count: a.count,
187
+ scramble: a.scramble,
150
188
  }))
151
189
  const loopConfigs = all
152
190
  .filter((a) => a.build)
package/LICENSE CHANGED
@@ -25,11 +25,11 @@ SOFTWARE.
25
25
  Third-party notice
26
26
 
27
27
  This project depends on GSAP (GreenSock Animation Platform), which is NOT
28
- covered by the MIT license above and is distributed separately.
28
+ covered by the MIT above and is distributed separately.
29
29
 
30
30
  GSAP is used under the Webflow Standard No-Charge GSAP License and is installed
31
31
  as a dependency from npm. GSAP remains the property of Webflow, Inc. / GreenSock
32
32
  and is subject to its own license terms, which take precedence over this MIT
33
33
  license with respect to GSAP itself.
34
34
 
35
- See https://gsap.com/standard-license/ for the applicable GSAP terms.
35
+ See https://gsap.com/standard-license/ for the applicable GSAP terms.