gclass-anims 1.0.0-beta.2 → 1.0.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AnimToggle.js +125 -5
- package/Animations.js +324 -31
- package/CHANGELOG.md +50 -0
- package/Config.js +43 -5
- package/LICENSE +2 -2
- package/Listeners.js +335 -52
- package/README.md +4 -4
- package/index.d.ts +47 -2
- package/package.json +5 -5
package/AnimToggle.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import initListeners from './Listeners'
|
|
1
|
+
import initListeners from './Listeners.js'
|
|
2
|
+
import { defaults, animations } from './Config.js'
|
|
3
|
+
import gsap from 'gsap'
|
|
2
4
|
|
|
3
5
|
// localStorage key controlling whether the GSAP animation system is mounted.
|
|
4
|
-
const STORAGE_KEY = '
|
|
6
|
+
const STORAGE_KEY = 'gclass-animations-enabled'
|
|
5
7
|
// localStorage key for a forced reduced-motion override (see
|
|
6
8
|
// enableReducedMotion / disableReducedMotion).
|
|
7
|
-
const REDUCED_KEY = '
|
|
9
|
+
const REDUCED_KEY = 'gclass-reduced-motion'
|
|
8
10
|
|
|
9
11
|
const reducedMotionQuery = typeof window !== 'undefined'
|
|
10
12
|
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
@@ -44,7 +46,7 @@ function readReducedOverride() {
|
|
|
44
46
|
// - Otherwise, if the user HAS an explicit stored choice, respect it
|
|
45
47
|
// (override wins), even under reduced motion.
|
|
46
48
|
// - Otherwise (no stored value) fall back to the default, which is ON unless
|
|
47
|
-
// reduced motion is detected
|
|
49
|
+
// reduced motion is detected - in which case animations are off.
|
|
48
50
|
function getEnabled() {
|
|
49
51
|
if (forcedReduced) return false
|
|
50
52
|
return stored === null ? !reduced : stored
|
|
@@ -111,12 +113,130 @@ export function disableReducedMotion() {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
let cleanup = null
|
|
116
|
+
let bootCleanup = null
|
|
117
|
+
let bootTimeout = null
|
|
118
|
+
let bootStyle = null
|
|
119
|
+
let hasBooted = false // true after first hard-load boot, skips boot on SPA path changes (remains false until first boot, resets on hard reload)
|
|
120
|
+
|
|
121
|
+
const readBootTime = (els, fallback) => {
|
|
122
|
+
let max = null
|
|
123
|
+
for (const el of els) {
|
|
124
|
+
const cls = [...el.classList].find(c => c.startsWith('boot-time-'))
|
|
125
|
+
if (cls) {
|
|
126
|
+
const n = Number(cls.slice('boot-time-'.length))
|
|
127
|
+
if (!Number.isNaN(n)) max = max === null ? n : Math.max(max, n)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return max ?? fallback
|
|
131
|
+
}
|
|
114
132
|
|
|
115
133
|
// Boots the GSAP animation system unless animations are disabled (stored "off"
|
|
116
134
|
// or reduced-motion fallback with no explicit choice). Idempotent: calling it
|
|
117
135
|
// again tears down any previous run first.
|
|
136
|
+
// Now also handles boot screen: any HTML/JSX with `.boot-up` anywhere is treated as the boot overlay.
|
|
137
|
+
// No separate initBoot needed - just call initAnimations().
|
|
138
|
+
// Boot stops all DOM rendering for defaults.bootTime (overwritten by boot-time-N class).
|
|
118
139
|
export function initAnimations() {
|
|
119
140
|
if (typeof window === 'undefined' || !getEnabled()) return
|
|
120
|
-
|
|
141
|
+
// boot already in progress (first mount in StrictMode) - ignore second mount
|
|
142
|
+
if (bootTimeout) {
|
|
143
|
+
console.log(`[initAnimations] boot already in progress - ignoring duplicate call`)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (cleanup) { cleanup(); cleanup = null }
|
|
147
|
+
if (bootCleanup) { bootCleanup(); bootCleanup = null }
|
|
148
|
+
if (bootStyle) { bootStyle.remove(); bootStyle = null; document.documentElement.classList.remove('gclass-booting') }
|
|
149
|
+
|
|
150
|
+
const hideBootEls = (els) => {
|
|
151
|
+
// React-safe: don't el.remove() - React owns the nodes and will throw
|
|
152
|
+
// insertBefore/removeChild on next commit if we mutate outside React.
|
|
153
|
+
// Hiding keeps React's tree intact but visually removes boot screen.
|
|
154
|
+
els.forEach(el => {
|
|
155
|
+
el.style.display = 'none'
|
|
156
|
+
el.setAttribute('hidden', '')
|
|
157
|
+
el.setAttribute('data-gclass-boot-hidden', '1')
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const bootEls = typeof document !== 'undefined' ? [...document.querySelectorAll(".boot-up")].filter(el => !el.hasAttribute('data-gclass-boot-hidden')) : []
|
|
162
|
+
if (!bootEls.length) {
|
|
163
|
+
// no .boot-up -> completely skip boot
|
|
164
|
+
} else if (bootEls.length > 1) {
|
|
165
|
+
console.error(`[initAnimations] Multiple .boot-up elements detected (${bootEls.length}) - skipping all boot animations`, bootEls)
|
|
166
|
+
hideBootEls(bootEls)
|
|
167
|
+
hasBooted = true
|
|
168
|
+
// fall through to normal initListeners without pausing DOM
|
|
169
|
+
} else if (hasBooted && !bootTimeout) {
|
|
170
|
+
// path change after already booted (SPA navigation) - skip boot, hard reload resets hasBooted
|
|
171
|
+
console.log(`[initAnimations] skipping boot on path change (already booted)`, bootEls)
|
|
172
|
+
hideBootEls(bootEls)
|
|
173
|
+
hasBooted = true
|
|
174
|
+
// fall through
|
|
175
|
+
} else {
|
|
176
|
+
const bootEl = bootEls[0]
|
|
177
|
+
const hasBootEnd = [...bootEl.classList].some(c => c.startsWith('boot-end-'))
|
|
178
|
+
if (!hasBootEnd) {
|
|
179
|
+
// no boot-end-* -> completely skip boot animation (still pause? spec says skip it)
|
|
180
|
+
// spec: skip boot-end animation if no class, but still do boot pause? user said "completely skip it if no .boot-end-<name> class is present"
|
|
181
|
+
// interpret as skip the exit animation only, still pause for bootTime
|
|
182
|
+
// To match "completely skip it" for boot-end, we just don't play exit tween
|
|
183
|
+
}
|
|
184
|
+
const bootTime = readBootTime(bootEls, defaults.bootTime ?? 2)
|
|
185
|
+
console.log(`[initAnimations] .boot-up found: ${bootEls.length} - pausing DOM for ${bootTime}s`, bootEls)
|
|
186
|
+
hasBooted = true
|
|
187
|
+
// stop all DOM rendering except .boot-up
|
|
188
|
+
bootStyle = document.createElement('style')
|
|
189
|
+
bootStyle.id = 'gclass-boot-style'
|
|
190
|
+
bootStyle.textContent = `html.gclass-booting{visibility:hidden} html.gclass-booting .boot-up,html.gclass-booting .boot-up *{visibility:visible} html.gclass-booting .boot-up{position:fixed;inset:0;z-index:9999;display:grid;place-items:center}`
|
|
191
|
+
document.head.appendChild(bootStyle)
|
|
192
|
+
document.documentElement.classList.add('gclass-booting')
|
|
193
|
+
// ensure boot els are visible even if nested inside hidden ancestors
|
|
194
|
+
bootEls.forEach(el => { el.style.visibility = 'visible' })
|
|
195
|
+
|
|
196
|
+
// animations inside boot screen must play while rest of DOM is hidden - init scoped to boot-up
|
|
197
|
+
bootCleanup = initListeners(bootEl)
|
|
198
|
+
|
|
199
|
+
bootTimeout = setTimeout(() => {
|
|
200
|
+
const bootEndCls = [...bootEl.classList].find(c => c.startsWith('boot-end-'))
|
|
201
|
+
if (!bootEndCls) {
|
|
202
|
+
// no boot-end -> skip exit animation, just hide
|
|
203
|
+
bootCleanup?.(); bootCleanup = null
|
|
204
|
+
document.documentElement.classList.remove('gclass-booting')
|
|
205
|
+
bootStyle?.remove(); bootStyle = null
|
|
206
|
+
hideBootEls(bootEls)
|
|
207
|
+
bootTimeout = null
|
|
208
|
+
cleanup = initListeners()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const name = bootEndCls.slice('boot-end-'.length) // e.g. spawn-blur
|
|
212
|
+
const cfg = animations.find(a => a.sel === '.' + name)
|
|
213
|
+
const from = cfg?.from
|
|
214
|
+
const easeCl = [...bootEl.classList].find(c => c.startsWith('ease-'))
|
|
215
|
+
const ease = easeCl ? easeCl.split('-')[1] : defaults.ease
|
|
216
|
+
const dur = readBootTime([bootEl], defaults.effectDuration ?? 1) // reuse boot-time- or fallback to effectDuration; if boot-time used for pause, reuse same value for exit unless overridden
|
|
217
|
+
// Actually use boot-end-time-N if present, else effectDuration
|
|
218
|
+
const endTimeCls = [...bootEl.classList].find(c => c.startsWith('boot-end-time-'))
|
|
219
|
+
const endDur = endTimeCls ? Number(endTimeCls.slice('boot-end-time-'.length)) : dur
|
|
220
|
+
|
|
221
|
+
const finish = () => {
|
|
222
|
+
bootCleanup?.(); bootCleanup = null
|
|
223
|
+
document.documentElement.classList.remove('gclass-booting')
|
|
224
|
+
bootStyle?.remove(); bootStyle = null
|
|
225
|
+
hideBootEls(bootEls)
|
|
226
|
+
bootTimeout = null
|
|
227
|
+
cleanup = initListeners()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!cfg || !from) {
|
|
231
|
+
console.warn(`[initAnimations] boot-end-${name} has no from state - removing without animation`)
|
|
232
|
+
finish()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
// play spawn in reverse (visible -> hidden) before removing
|
|
236
|
+
gsap.to(bootEl, { ...from, duration: endDur, ease, onComplete: finish })
|
|
237
|
+
}, bootTime * 1000)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
121
241
|
cleanup = initListeners()
|
|
122
242
|
}
|
package/Animations.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { Flip, SplitText, TextPlugin } from "gsap/all";
|
|
1
|
+
import { DrawSVGPlugin, Flip, MotionPathPlugin, ScrambleTextPlugin, SplitText, TextPlugin } from "gsap/all";
|
|
2
2
|
import gsap from "gsap";
|
|
3
|
+
import { defaults } from './Config.js'
|
|
3
4
|
|
|
4
5
|
gsap.registerPlugin(Flip)
|
|
5
6
|
gsap.registerPlugin(SplitText)
|
|
6
7
|
gsap.registerPlugin(TextPlugin)
|
|
8
|
+
gsap.registerPlugin(DrawSVGPlugin)
|
|
9
|
+
gsap.registerPlugin(MotionPathPlugin)
|
|
10
|
+
gsap.registerPlugin(ScrambleTextPlugin)
|
|
7
11
|
|
|
8
12
|
// A tasteful fallback whenever a call site omits an ease, so the animation
|
|
9
13
|
// never lapses into the raw "none" look. Callers still override this freely.
|
|
@@ -28,6 +32,23 @@ export const finalOpacity = (target) => {
|
|
|
28
32
|
return isNaN(v) ? 1 : v
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
// TextPlugin tweens take their endpoints from the LIVE DOM: the `.typewriter`
|
|
36
|
+
// play callbacks pass `el.innerHTML` as the text to type. The tween's from
|
|
37
|
+
// state ("") is applied the instant the tween is created, and a teardown that
|
|
38
|
+
// kills the tween mid-flight leaves that wiped state behind - so a later
|
|
39
|
+
// engine re-init reading `el.innerHTML` again would type an empty (or
|
|
40
|
+
// partially-typed) string forever. Stash the full HTML on first sight and
|
|
41
|
+
// reuse it. The stash only refreshes from SETTLED content: never while a
|
|
42
|
+
// typewriter tween on the element is actively rendering partial progress, and
|
|
43
|
+
// never from a blank DOM. Legit content changes (React re-renders, dynamic
|
|
44
|
+
// `.appear` elements) therefore update the stash naturally.
|
|
45
|
+
export const stashText = (el) => {
|
|
46
|
+
const busy = (el.typewriter || el._spawnTween || el._scrollTween)?.isActive?.()
|
|
47
|
+
const html = el.innerHTML
|
|
48
|
+
if (!busy && html && html.trim()) el._gcText = html
|
|
49
|
+
return el._gcText !== undefined ? el._gcText : html
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
|
|
32
53
|
//Spawn animations
|
|
33
54
|
|
|
@@ -58,7 +79,11 @@ export function expandA (target , delay , dur , ease){
|
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
export function typewriter (target , text , dur , delay , ease){
|
|
61
|
-
|
|
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,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `gclass-anims` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.0.0-beta.20] - 2026-9-2
|
|
6
|
+
- Fix ESM strict import: `AnimToggle.js` and `Listeners.js` now use `.js` extensions (`Remix`/`Qwik` Node ESM `Cannot find module` fix)
|
|
7
|
+
- Fix `Lit` shadow DOM `works.txt` `no` → light DOM default (`createRenderRoot(){return this}`) + `initListeners(shadowRoot)` docs
|
|
8
|
+
- Fix `SvelteKit` `ERESOLVE` (`@sveltejs/vite-plugin-svelte 4` → `5.1` for `vite@6`) + missing `src/app.html`
|
|
9
|
+
- Fix `Qwik` `entry.ssr not found` → add `src/root.tsx`+`entry.ssr.tsx`+`tsconfig.json`
|
|
10
|
+
- Fix `SolidStart` Vinxi `503` → simplified to `vite-plugin-solid` SPA (same fine-grained model)
|
|
11
|
+
- Reverted license `LGPL-3.0-only` → `MIT` (anywhere GSAP is usable)
|
|
12
|
+
|
|
13
|
+
## [1.0.0-beta.19] - 2026-9-1
|
|
14
|
+
- Fixed the SplitText animations formatting and removed Boot.js
|
|
15
|
+
|
|
16
|
+
## [1.0.0-beta.18] - 2026-9-1
|
|
17
|
+
- Hopefully finally fixed `.boot-up` properly skipping on path changes
|
|
18
|
+
|
|
19
|
+
## [1.0.0-beta.17] - 2026-9-1
|
|
20
|
+
- Fixed the `.boot-up` class firing on every path change
|
|
21
|
+
|
|
22
|
+
## [1.0.0-beta.16] - 2026-09-1
|
|
23
|
+
- Added a new `.boot-up` class for boot up animations
|
|
24
|
+
- Fixed text animations not taking formatting into account
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## [1.0.0-beta.13] - 2026-08-27
|
|
28
|
+
- Added a new `.fill-svg` modifier for the `.draw` and `.draw-split` classes that fills the SVG after it has been drawn.
|
|
29
|
+
|
|
30
|
+
## [1.0.0-beta.12] - 2026-08-26
|
|
31
|
+
|
|
32
|
+
- Fixed `scramble` with `scroll-progress` throwing `can't convert undefined to object` - `computeTo` (`Listeners.js:424`) and scrub `to` builder (`Listeners.js:867`) now guard `from` (`scramble` has no `from`).
|
|
33
|
+
|
|
34
|
+
## [1.0.0-beta.11] - 2026-08-26
|
|
35
|
+
|
|
36
|
+
- Fixed `.draw-split` infinite loop when paired with `.appear` - `splitPaths` (`Animations.js:244`) now strips `appear`/`scroll`/`scroll-progress`/`draw`/`draw-split`/`data-gsap-*` from cloned segments, marks children with `data-gsap-split` + `contain:paint`/`will-change:transform` isolation, and prevents `appearObserver` (`Listeners.js:1559`) re-triggering. Also isolated `draw-split` demos in docs.
|
|
37
|
+
|
|
38
|
+
## [1.0.0-beta.10] - 2026-08-26
|
|
39
|
+
|
|
40
|
+
- Added `.randomize-<prop>-[min]-[max]` - randomize spawn start values per element (e.g. `randomize-rotation-[-90]-[90]`, `randomize-x-[-40]-[40]`). Re-rolls on every replay (`.scroll` re-enter, `.appear`).
|
|
41
|
+
- Added `.draw` - stroke-draw reveal for SVG paths using DrawSVGPlugin (`drawSVG: 0% → 100%`).
|
|
42
|
+
- Added `.draw-split` - draws multi-segment SVG paths sequentially at constant pen speed (splits paths with multiple `M` commands into individual strokes).
|
|
43
|
+
- Added `.scramble` - text resolves from empty through scrambled characters into real content (ScrambleTextPlugin). Supports `.reveal-delay-N`, `.chars-[...]`, `.amount-N`, `.scramble-rtl`.
|
|
44
|
+
- Added `.scramble-all` - variant of scramble with no empty start; the finished string flips to garbage as a whole then sweeps back.
|
|
45
|
+
- Added `.scroll-frame` - use a scrollable container as the ScrollTrigger scroller for nested `.scroll` / `.scroll-progress` elements (innermost `.scroll-frame` ancestor wins).
|
|
46
|
+
- Fixed `spawn-text-*` (SplitText) not working correctly on flex containers - text runs are now wrapped in block containers before splitting to preserve flex layout, spacing, and line grouping.
|
|
47
|
+
|
|
48
|
+
## [1.0.0-beta.9] - Previous release
|
|
49
|
+
|
|
50
|
+
- See git history for earlier changes.
|