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/AnimToggle.js +199 -7
- package/Animations.js +324 -31
- package/CHANGELOG.md +53 -0
- package/Config.js +43 -5
- package/LICENSE +2 -2
- package/Listeners.js +450 -95
- package/README.md +4 -4
- package/index.d.ts +73 -4
- package/index.js +1 -1
- package/package.json +5 -5
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|