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