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

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/AnimToggle.js CHANGED
@@ -1,10 +1,12 @@
1
- import initListeners from './Listeners'
1
+ import initListeners from './Listeners.js'
2
+ import { defaults, animations } from './Config.js'
3
+ import gsap from 'gsap'
2
4
 
3
5
  // localStorage key controlling whether the GSAP animation system is mounted.
4
- const STORAGE_KEY = 'funbyte-animations-enabled'
6
+ const STORAGE_KEY = 'gclass-animations-enabled'
5
7
  // localStorage key for a forced reduced-motion override (see
6
8
  // enableReducedMotion / disableReducedMotion).
7
- const REDUCED_KEY = 'funbyte-reduced-motion'
9
+ const REDUCED_KEY = 'gclass-reduced-motion'
8
10
 
9
11
  const reducedMotionQuery = typeof window !== 'undefined'
10
12
  ? window.matchMedia('(prefers-reduced-motion: reduce)')
@@ -44,7 +46,7 @@ function readReducedOverride() {
44
46
  // - Otherwise, if the user HAS an explicit stored choice, respect it
45
47
  // (override wins), even under reduced motion.
46
48
  // - Otherwise (no stored value) fall back to the default, which is ON unless
47
- // reduced motion is detected in which case animations are off.
49
+ // reduced motion is detected - in which case animations are off.
48
50
  function getEnabled() {
49
51
  if (forcedReduced) return false
50
52
  return stored === null ? !reduced : stored
@@ -111,12 +113,130 @@ export function disableReducedMotion() {
111
113
  }
112
114
 
113
115
  let cleanup = null
116
+ let bootCleanup = null
117
+ let bootTimeout = null
118
+ let bootStyle = null
119
+ let hasBooted = false // true after first hard-load boot, skips boot on SPA path changes (remains false until first boot, resets on hard reload)
120
+
121
+ const readBootTime = (els, fallback) => {
122
+ let max = null
123
+ for (const el of els) {
124
+ const cls = [...el.classList].find(c => c.startsWith('boot-time-'))
125
+ if (cls) {
126
+ const n = Number(cls.slice('boot-time-'.length))
127
+ if (!Number.isNaN(n)) max = max === null ? n : Math.max(max, n)
128
+ }
129
+ }
130
+ return max ?? fallback
131
+ }
114
132
 
115
133
  // Boots the GSAP animation system unless animations are disabled (stored "off"
116
134
  // or reduced-motion fallback with no explicit choice). Idempotent: calling it
117
135
  // again tears down any previous run first.
136
+ // Now also handles boot screen: any HTML/JSX with `.boot-up` anywhere is treated as the boot overlay.
137
+ // No separate initBoot needed - just call initAnimations().
138
+ // Boot stops all DOM rendering for defaults.bootTime (overwritten by boot-time-N class).
118
139
  export function initAnimations() {
119
140
  if (typeof window === 'undefined' || !getEnabled()) return
120
- if (cleanup) cleanup()
141
+ // boot already in progress (first mount in StrictMode) - ignore second mount
142
+ if (bootTimeout) {
143
+ console.log(`[initAnimations] boot already in progress - ignoring duplicate call`)
144
+ return
145
+ }
146
+ if (cleanup) { cleanup(); cleanup = null }
147
+ if (bootCleanup) { bootCleanup(); bootCleanup = null }
148
+ if (bootStyle) { bootStyle.remove(); bootStyle = null; document.documentElement.classList.remove('gclass-booting') }
149
+
150
+ const hideBootEls = (els) => {
151
+ // React-safe: don't el.remove() - React owns the nodes and will throw
152
+ // insertBefore/removeChild on next commit if we mutate outside React.
153
+ // Hiding keeps React's tree intact but visually removes boot screen.
154
+ els.forEach(el => {
155
+ el.style.display = 'none'
156
+ el.setAttribute('hidden', '')
157
+ el.setAttribute('data-gclass-boot-hidden', '1')
158
+ })
159
+ }
160
+
161
+ const bootEls = typeof document !== 'undefined' ? [...document.querySelectorAll(".boot-up")].filter(el => !el.hasAttribute('data-gclass-boot-hidden')) : []
162
+ if (!bootEls.length) {
163
+ // no .boot-up -> completely skip boot
164
+ } else if (bootEls.length > 1) {
165
+ console.error(`[initAnimations] Multiple .boot-up elements detected (${bootEls.length}) - skipping all boot animations`, bootEls)
166
+ hideBootEls(bootEls)
167
+ hasBooted = true
168
+ // fall through to normal initListeners without pausing DOM
169
+ } else if (hasBooted && !bootTimeout) {
170
+ // path change after already booted (SPA navigation) - skip boot, hard reload resets hasBooted
171
+ console.log(`[initAnimations] skipping boot on path change (already booted)`, bootEls)
172
+ hideBootEls(bootEls)
173
+ hasBooted = true
174
+ // fall through
175
+ } else {
176
+ const bootEl = bootEls[0]
177
+ const hasBootEnd = [...bootEl.classList].some(c => c.startsWith('boot-end-'))
178
+ if (!hasBootEnd) {
179
+ // no boot-end-* -> completely skip boot animation (still pause? spec says skip it)
180
+ // spec: skip boot-end animation if no class, but still do boot pause? user said "completely skip it if no .boot-end-<name> class is present"
181
+ // interpret as skip the exit animation only, still pause for bootTime
182
+ // To match "completely skip it" for boot-end, we just don't play exit tween
183
+ }
184
+ const bootTime = readBootTime(bootEls, defaults.bootTime ?? 2)
185
+ console.log(`[initAnimations] .boot-up found: ${bootEls.length} - pausing DOM for ${bootTime}s`, bootEls)
186
+ hasBooted = true
187
+ // stop all DOM rendering except .boot-up
188
+ bootStyle = document.createElement('style')
189
+ bootStyle.id = 'gclass-boot-style'
190
+ bootStyle.textContent = `html.gclass-booting{visibility:hidden} html.gclass-booting .boot-up,html.gclass-booting .boot-up *{visibility:visible} html.gclass-booting .boot-up{position:fixed;inset:0;z-index:9999;display:grid;place-items:center}`
191
+ document.head.appendChild(bootStyle)
192
+ document.documentElement.classList.add('gclass-booting')
193
+ // ensure boot els are visible even if nested inside hidden ancestors
194
+ bootEls.forEach(el => { el.style.visibility = 'visible' })
195
+
196
+ // animations inside boot screen must play while rest of DOM is hidden - init scoped to boot-up
197
+ bootCleanup = initListeners(bootEl)
198
+
199
+ bootTimeout = setTimeout(() => {
200
+ const bootEndCls = [...bootEl.classList].find(c => c.startsWith('boot-end-'))
201
+ if (!bootEndCls) {
202
+ // no boot-end -> skip exit animation, just hide
203
+ bootCleanup?.(); bootCleanup = null
204
+ document.documentElement.classList.remove('gclass-booting')
205
+ bootStyle?.remove(); bootStyle = null
206
+ hideBootEls(bootEls)
207
+ bootTimeout = null
208
+ cleanup = initListeners()
209
+ return
210
+ }
211
+ const name = bootEndCls.slice('boot-end-'.length) // e.g. spawn-blur
212
+ const cfg = animations.find(a => a.sel === '.' + name)
213
+ const from = cfg?.from
214
+ const easeCl = [...bootEl.classList].find(c => c.startsWith('ease-'))
215
+ const ease = easeCl ? easeCl.split('-')[1] : defaults.ease
216
+ const dur = readBootTime([bootEl], defaults.effectDuration ?? 1) // reuse boot-time- or fallback to effectDuration; if boot-time used for pause, reuse same value for exit unless overridden
217
+ // Actually use boot-end-time-N if present, else effectDuration
218
+ const endTimeCls = [...bootEl.classList].find(c => c.startsWith('boot-end-time-'))
219
+ const endDur = endTimeCls ? Number(endTimeCls.slice('boot-end-time-'.length)) : dur
220
+
221
+ const finish = () => {
222
+ bootCleanup?.(); bootCleanup = null
223
+ document.documentElement.classList.remove('gclass-booting')
224
+ bootStyle?.remove(); bootStyle = null
225
+ hideBootEls(bootEls)
226
+ bootTimeout = null
227
+ cleanup = initListeners()
228
+ }
229
+
230
+ if (!cfg || !from) {
231
+ console.warn(`[initAnimations] boot-end-${name} has no from state - removing without animation`)
232
+ finish()
233
+ return
234
+ }
235
+ // play spawn in reverse (visible -> hidden) before removing
236
+ gsap.to(bootEl, { ...from, duration: endDur, ease, onComplete: finish })
237
+ }, bootTime * 1000)
238
+ return
239
+ }
240
+
121
241
  cleanup = initListeners()
122
242
  }
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,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to `gclass-anims` will be documented in this file.
4
+
5
+ ## [1.0.0-beta.20] - 2026-9-2
6
+ - Fix ESM strict import: `AnimToggle.js` and `Listeners.js` now use `.js` extensions (`Remix`/`Qwik` Node ESM `Cannot find module` fix)
7
+ - Fix `Lit` shadow DOM `works.txt` `no` → light DOM default (`createRenderRoot(){return this}`) + `initListeners(shadowRoot)` docs
8
+ - Fix `SvelteKit` `ERESOLVE` (`@sveltejs/vite-plugin-svelte 4` → `5.1` for `vite@6`) + missing `src/app.html`
9
+ - Fix `Qwik` `entry.ssr not found` → add `src/root.tsx`+`entry.ssr.tsx`+`tsconfig.json`
10
+ - Fix `SolidStart` Vinxi `503` → simplified to `vite-plugin-solid` SPA (same fine-grained model)
11
+ - Reverted license `LGPL-3.0-only` → `MIT` (anywhere GSAP is usable)
12
+
13
+ ## [1.0.0-beta.19] - 2026-9-1
14
+ - Fixed the SplitText animations formatting and removed Boot.js
15
+
16
+ ## [1.0.0-beta.18] - 2026-9-1
17
+ - Hopefully finally fixed `.boot-up` properly skipping on path changes
18
+
19
+ ## [1.0.0-beta.17] - 2026-9-1
20
+ - Fixed the `.boot-up` class firing on every path change
21
+
22
+ ## [1.0.0-beta.16] - 2026-09-1
23
+ - Added a new `.boot-up` class for boot up animations
24
+ - Fixed text animations not taking formatting into account
25
+
26
+
27
+ ## [1.0.0-beta.13] - 2026-08-27
28
+ - Added a new `.fill-svg` modifier for the `.draw` and `.draw-split` classes that fills the SVG after it has been drawn.
29
+
30
+ ## [1.0.0-beta.12] - 2026-08-26
31
+
32
+ - 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`).
33
+
34
+ ## [1.0.0-beta.11] - 2026-08-26
35
+
36
+ - 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.
37
+
38
+ ## [1.0.0-beta.10] - 2026-08-26
39
+
40
+ - 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`).
41
+ - Added `.draw` - stroke-draw reveal for SVG paths using DrawSVGPlugin (`drawSVG: 0% → 100%`).
42
+ - Added `.draw-split` - draws multi-segment SVG paths sequentially at constant pen speed (splits paths with multiple `M` commands into individual strokes).
43
+ - Added `.scramble` - text resolves from empty through scrambled characters into real content (ScrambleTextPlugin). Supports `.reveal-delay-N`, `.chars-[...]`, `.amount-N`, `.scramble-rtl`.
44
+ - Added `.scramble-all` - variant of scramble with no empty start; the finished string flips to garbage as a whole then sweeps back.
45
+ - Added `.scroll-frame` - use a scrollable container as the ScrollTrigger scroller for nested `.scroll` / `.scroll-progress` elements (innermost `.scroll-frame` ancestor wins).
46
+ - 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.
47
+
48
+ ## [1.0.0-beta.9] - Previous release
49
+
50
+ - See git history for earlier changes.