gclass-anims 1.0.0-beta.1

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 ADDED
@@ -0,0 +1,122 @@
1
+ import initListeners from './Listeners'
2
+
3
+ // localStorage key controlling whether the GSAP animation system is mounted.
4
+ const STORAGE_KEY = 'funbyte-animations-enabled'
5
+ // localStorage key for a forced reduced-motion override (see
6
+ // enableReducedMotion / disableReducedMotion).
7
+ const REDUCED_KEY = 'funbyte-reduced-motion'
8
+
9
+ const reducedMotionQuery = typeof window !== 'undefined'
10
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
11
+ : null
12
+
13
+ // Module-level singleton store so anything subscribing to it stays in sync.
14
+ let stored = readStored()
15
+ let reduced = reducedMotionQuery?.matches ?? false
16
+ let forcedReduced = readReducedOverride()
17
+ const subscribers = new Set()
18
+
19
+ // Returns the stored preference, or null when the user has never explicitly
20
+ // chosen (no localStorage value). Distinct from a boolean so we can tell
21
+ // "user override" apart from "use the default".
22
+ function readStored() {
23
+ if (typeof window === 'undefined') return null
24
+ try {
25
+ const raw = localStorage.getItem(STORAGE_KEY)
26
+ return raw === null ? null : raw === 'true'
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ // Returns whether animations were force-disabled via enableReducedMotion().
33
+ function readReducedOverride() {
34
+ if (typeof window === 'undefined') return false
35
+ try {
36
+ return localStorage.getItem(REDUCED_KEY) === 'true'
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ // Enabled rule:
43
+ // - A forced reduced-motion override always wins (animations off).
44
+ // - Otherwise, if the user HAS an explicit stored choice, respect it
45
+ // (override wins), even under reduced motion.
46
+ // - Otherwise (no stored value) fall back to the default, which is ON unless
47
+ // reduced motion is detected — in which case animations are off.
48
+ function getEnabled() {
49
+ if (forcedReduced) return false
50
+ return stored === null ? !reduced : stored
51
+ }
52
+
53
+ function emit() {
54
+ subscribers.forEach((fn) => fn(getEnabled()))
55
+ }
56
+
57
+ // React to the OS reduced-motion setting live (no reload). Toggling off
58
+ // reverts tweens; toggling back on re-applies them.
59
+ if (reducedMotionQuery) {
60
+ reducedMotionQuery.addEventListener('change', () => {
61
+ reduced = reducedMotionQuery.matches
62
+ emit()
63
+ })
64
+ }
65
+
66
+ function subscribe(cb) {
67
+ subscribers.add(cb)
68
+ return () => subscribers.delete(cb)
69
+ }
70
+
71
+ function getSnapshot() {
72
+ return getEnabled()
73
+ }
74
+
75
+ // Persists the new value, then reloads the page so the change applies cleanly.
76
+ export function toggleAnimations() {
77
+ stored = !getEnabled()
78
+ try {
79
+ localStorage.setItem(STORAGE_KEY, String(stored))
80
+ } catch {
81
+ /* ignore storage errors (private mode etc.) */
82
+ }
83
+ emit()
84
+ if (typeof window !== 'undefined') window.location.reload()
85
+ }
86
+
87
+ // Force animations off (a hard override that wins over any stored preference),
88
+ // then reload so the running instance tears down. Use disableReducedMotion() to
89
+ // clear it.
90
+ export function enableReducedMotion() {
91
+ forcedReduced = true
92
+ try {
93
+ localStorage.setItem(REDUCED_KEY, 'true')
94
+ } catch {
95
+ /* ignore storage errors */
96
+ }
97
+ emit()
98
+ if (typeof window !== 'undefined') window.location.reload()
99
+ }
100
+
101
+ // Clear the forced reduced-motion override (if any), then reload.
102
+ export function disableReducedMotion() {
103
+ forcedReduced = false
104
+ try {
105
+ localStorage.removeItem(REDUCED_KEY)
106
+ } catch {
107
+ /* ignore storage errors */
108
+ }
109
+ emit()
110
+ if (typeof window !== 'undefined') window.location.reload()
111
+ }
112
+
113
+ let cleanup = null
114
+
115
+ // Boots the GSAP animation system unless animations are disabled (stored "off"
116
+ // or reduced-motion fallback with no explicit choice). Idempotent: calling it
117
+ // again tears down any previous run first.
118
+ export function initAnimations() {
119
+ if (typeof window === 'undefined' || !getEnabled()) return
120
+ if (cleanup) cleanup()
121
+ cleanup = initListeners()
122
+ }
package/Animations.js ADDED
@@ -0,0 +1,436 @@
1
+ import { Flip, SplitText, TextPlugin } from "gsap/all";
2
+ import gsap from "gsap";
3
+
4
+ gsap.registerPlugin(Flip)
5
+ gsap.registerPlugin(SplitText)
6
+ gsap.registerPlugin(TextPlugin)
7
+
8
+ // A tasteful fallback whenever a call site omits an ease, so the animation
9
+ // never lapses into the raw "none" look. Callers still override this freely.
10
+ const DEFAULT_EASE = "power3.out";
11
+ const easeOf = (e) => e || DEFAULT_EASE;
12
+
13
+ // These animations treat `amount` as a scale multiplier, but dampened by a
14
+ // factor of 10: `amount-20` scales to 2x rather than 20x. The target scale is
15
+ // `amount / 10`, so use `amount-N` as a percentage-style number.
16
+
17
+ // The resting opacity the element should settle on. GSAP sets the spawn's
18
+ // `from` state (opacity 0) on the element before the tween is built, so
19
+ // `getComputedStyle` would read that transient 0 rather than the intended
20
+ // value. Temporarily drop the inline opacity to read the CSS-defined one (e.g.
21
+ // a `disabled:opacity-50`, a `.opacity-*` utility, or the default 1), then
22
+ // restore it so the actual animation still starts from the right place.
23
+ export const finalOpacity = (target) => {
24
+ const had = target.style.opacity
25
+ target.style.removeProperty("opacity")
26
+ const v = parseFloat(getComputedStyle(target).opacity)
27
+ if (had !== "") target.style.opacity = had
28
+ return isNaN(v) ? 1 : v
29
+ }
30
+
31
+
32
+ //Spawn animations
33
+
34
+ export function SpawnV (target , delay , dir , dur , ease) {
35
+ const e = easeOf(ease)
36
+ // A whisper of scale in the same breath as the travel keeps the reveal
37
+ // feeling physical instead of a flat 2D slide.
38
+ return gsap.fromTo(target , {opacity:0 , y:dir , scale:0.97} , {ease:e , duration:dur , delay:delay , y:0 , opacity:finalOpacity(target) , scale:1})
39
+ }
40
+
41
+ export function SpawnH (target , delay , dir , dur , ease) {
42
+ const e = easeOf(ease)
43
+ return gsap.fromTo(target , {opacity:0 , x:dir , scale:0.97} , {ease:e , duration:dur , delay:delay , x:0 , opacity:finalOpacity(target) , scale:1})
44
+ }
45
+
46
+ export function expandV (target , delay , dur , ease){
47
+ const e = easeOf(ease)
48
+ return gsap.fromTo(target , {opacity:1 , scaleY:0} , {ease:e , duration:dur , delay:delay , scaleY:1 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
49
+ }
50
+
51
+ export function expandH (target , delay , dur , ease){
52
+ const e = easeOf(ease)
53
+ return gsap.fromTo(target , {opacity:1 , scaleX:0} , {ease:e , duration:dur , delay:delay , scaleX:1 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
54
+ }
55
+ export function expandA (target , delay , dur , ease){
56
+ const e = easeOf(ease)
57
+ return gsap.fromTo(target , {opacity:1 , scale:0} , {ease:e , duration:dur , delay:delay , scale:1 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
58
+ }
59
+
60
+ export function typewriter (target , text , dur , delay , ease){
61
+ return gsap.fromTo(target , {text:""} , {ease:easeOf(ease) , duration:dur , delay:delay , text:text})
62
+ }
63
+
64
+ export function spawnSpinCCW (target , delay , dur , ease){
65
+ const e = easeOf(ease)
66
+ return gsap.fromTo(target , {scale:0 , rotation:-90} , {ease:e , duration:dur , delay:delay , scale:1 , rotation:0 , transformOrigin:"50% 50%"})
67
+ }
68
+
69
+ export function spawnSpinCW (target , delay , dur , ease){
70
+ const e = easeOf(ease)
71
+ return gsap.fromTo(target , {scale:0 , rotation:90} , {ease:e , duration:dur , delay:delay , scale:1 , rotation:0 , transformOrigin:"50% 50%"})
72
+ }
73
+
74
+ export function spawnFade (target , delay , dur , ease){
75
+ return gsap.fromTo(target , {opacity:0} , {ease:easeOf(ease) , duration:dur , delay:delay , opacity:finalOpacity(target)})
76
+ }
77
+
78
+ export function spawnBlur (target , delay , dur , ease){
79
+ const e = easeOf(ease)
80
+ // Linger the blur slightly so the focus pull feels deliberate, not abrupt.
81
+ return gsap.fromTo(target , {opacity:0 , filter:"blur(20px)"} , {ease:e , duration:dur , delay:delay , opacity:finalOpacity(target) , filter:"blur(0px)"})
82
+ }
83
+
84
+ // Clip-path reveal: the element's box is wiped open from a chosen edge. `dir`
85
+ // is one of up/down/left/right and picks which inset collapses to zero so the
86
+ // wipe travels from that edge:
87
+ // up - hidden at the bottom, wipes open upward (bottom -> top)
88
+ // down - hidden at the top, wipes open downward (top -> bottom)
89
+ // left - hidden on the right, wipes open leftward (right -> left)
90
+ // right - hidden on the left, wipes open rightward (left -> right)
91
+ // The `from` inset is mirrored in the Config entry so
92
+ // `.scroll`/`.scroll-progress`/`.leave` reversal and `.appear` all know the
93
+ // hidden state. No opacity is involved — pure clip wipe.
94
+ const CLIP_FROM = {
95
+ up: "inset(0% 0% 100% 0%)",
96
+ down: "inset(100% 0% 0% 0%)",
97
+ left: "inset(0% 0% 0% 100%)",
98
+ right: "inset(0% 100% 0% 0%)",
99
+ }
100
+ export function spawnClipReveal (target , delay , dur , ease , dir = "up"){
101
+ const from = CLIP_FROM[dir] || CLIP_FROM.up
102
+ return gsap.fromTo(target , {clipPath: from} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
103
+ }
104
+
105
+ // Curtain reveal: opens outward from the horizontal centre — a vertical slit in
106
+ // the middle widens left and right until the whole box is shown.
107
+ export function curtainHorizontal (target , delay , dur , ease){
108
+ return gsap.fromTo(target , {clipPath:"inset(0% 50% 0% 50%)"} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
109
+ }
110
+
111
+ // Curtain reveal: opens outward from the vertical centre — a horizontal slit in
112
+ // the middle widens up and down until the whole box is shown.
113
+ export function curtainVertical (target , delay , dur , ease){
114
+ return gsap.fromTo(target , {clipPath:"inset(50% 0% 50% 0%)"} , {clipPath:"inset(0% 0% 0% 0%)" , ease:easeOf(ease) , duration:dur , delay:delay})
115
+ }
116
+
117
+ export function spawnXUp (target , delay , dur , ease){
118
+ const e = easeOf(ease)
119
+ // Card-spawn: a full 360° front-flip that unfolds into place, with a soft
120
+ // depth scale and an edge anchor so the pivot reads like a flipping card.
121
+ return gsap.fromTo(target , {scale:0 , rotationX:360 , opacity:0} , {ease:e , duration:dur , delay:delay , scale:1 , rotationX:0 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
122
+ }
123
+
124
+ export function spawnXDown (target , delay , dur , ease){
125
+ const e = easeOf(ease)
126
+ return gsap.fromTo(target , {scale:0 , rotationX:-360 , opacity:0} , {ease:e , duration:dur , delay:delay , scale:1 , rotationX:0 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
127
+ }
128
+
129
+ export function spawnYRight (target , delay , dur , ease){
130
+ const e = easeOf(ease)
131
+ return gsap.fromTo(target , {scale:0 , rotationY:360 , opacity:0} , {ease:e , duration:dur , delay:delay , scale:1 , rotationY:0 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
132
+ }
133
+
134
+ export function spawnYLeft (target , delay , dur , ease){
135
+ const e = easeOf(ease)
136
+ return gsap.fromTo(target , {scale:0 , rotationY:-360 , opacity:0} , {ease:e , duration:dur , delay:delay , scale:1 , rotationY:0 , opacity:finalOpacity(target) , transformOrigin:"50% 50%"})
137
+ }
138
+
139
+ export function expandRight (target , delay , dur , ease){
140
+ const e = easeOf(ease)
141
+ const tl = gsap.timeline()
142
+ tl.set(target , {transformOrigin : "100% 50%"})
143
+ .fromTo(target , {opacity:1 , scaleX:0} , {ease:e , duration:dur , delay:delay , scaleX:1 , opacity:finalOpacity(target)})
144
+
145
+ return tl
146
+
147
+ }
148
+
149
+ export function expandLeft (target , delay , dur , ease){
150
+ const e = easeOf(ease)
151
+ const tl = gsap.timeline()
152
+ tl.set(target , {transformOrigin : "0% 50%"})
153
+ .fromTo(target , {opacity:1 , scaleX:0} , {ease:e , duration:dur , delay:delay , scaleX:1 , opacity:finalOpacity(target)})
154
+
155
+
156
+ return tl
157
+ }
158
+
159
+ export function expandUp (target , delay , dur , ease){
160
+ const e = easeOf(ease)
161
+ const tl = gsap.timeline()
162
+ tl.set(target , {transformOrigin : "50% 100%"})
163
+ .fromTo(target , {opacity:1 , scaleY:0} , {ease:e , duration:dur , delay:delay , scaleY:1 , opacity:finalOpacity(target)})
164
+
165
+ return tl
166
+ }
167
+
168
+ export function expandDown (target , delay , dur , ease){
169
+ const e = easeOf(ease)
170
+ const tl = gsap.timeline()
171
+ tl.set(target , {transformOrigin : "50% 0%"})
172
+ .fromTo(target , {opacity:1 , scaleY:0} , {ease:e , duration:dur , delay:delay , scaleY:1 , opacity:finalOpacity(target)})
173
+
174
+ return tl
175
+ }
176
+
177
+ export function countTargetVars (target){
178
+ // Cache on the element so the target number survives a `.scroll` reverse
179
+ // (which rewrites textContent back to the start value). Re-reading it from
180
+ // the live text each play would otherwise collapse the range to start==end.
181
+ if (target._countTarget) return target._countTarget
182
+ // Extract the number from the element, ignoring any surrounding text
183
+ // (e.g. "$1,250 total" -> 1250). Keeps decimals so 3.14 counts to 3.14.
184
+ const match = (target.textContent || "0").replace(/,/g, "").match(/-?\d+(?:\.\d+)?/)
185
+ const end = match ? parseFloat(match[0]) : 0
186
+ const decimals = match?.[0].includes(".") ? (match[0].split(".")[1] || "").length : 0
187
+ // Count FROM `.spawn-num-N` (N = the starting number) up to `end`. When the
188
+ // class is absent, fall back to 0.
189
+ const startCls = [...target.classList].find(c => c.startsWith("spawn-num-"))
190
+ const start = startCls ? parseFloat(startCls.slice("spawn-num-".length)) : 0
191
+ return target._countTarget = { start, end, decimals }
192
+ }
193
+
194
+ export function countUp (target , delay , dur, ease){
195
+ const e = easeOf(ease)
196
+ const { start, end, decimals } = countTargetVars(target)
197
+ const obj = { n: start }
198
+ // Pure counter: animates the number only, leaving opacity untouched, so it
199
+ // composes cleanly with `.scroll` / `.scroll-progress` / `.appear` /
200
+ // `.leave` without imposing a fade.
201
+ return gsap.timeline({ delay }).fromTo(obj , { n: start } , { n: end , duration:dur , ease:e , onUpdate: () => { target.textContent = obj.n.toFixed(decimals) } } , 0)
202
+ }
203
+
204
+
205
+ //Mouse animations
206
+
207
+ export function verticalmove (target , amount , dur , ease){
208
+ const e = easeOf(ease)
209
+ // A hint of scale makes the lift read as pressing/pulling rather than a
210
+ // detached translate, and easing by weight keeps it from feeling springy.
211
+ return gsap.to(target , {y:amount , scale:1 + amount / 1000 , duration:dur , ease:e})
212
+ }
213
+
214
+ export function expandmove (target , amount , dur , ease){
215
+ // `amount` uses the dampened `amount-N` scale system: the target scale is
216
+ // `amount / 10`. Rest state (scale 1) is therefore amount = 10.
217
+ return gsap.to(target , {scale:amount / 10 , duration:dur , ease:easeOf(ease)})
218
+ }
219
+
220
+ export function magnet (target , x , y , scale , dur , ease){
221
+ // Drives a cursor-attracted element: translate toward the given point while
222
+ // scaling up. `overwrite:"auto"` kills the in-flight tween on every
223
+ // mousemove so motion stays snappy instead of queuing up behind itself.
224
+ // Passing x:0, y:0, scale:1 resets it back to rest on mouseleave.
225
+ return gsap.to(target , {x , y , scale , duration:dur , ease:easeOf(ease) , overwrite:"auto"})
226
+ }
227
+
228
+ export function magnet3d (target , x , y , scale , rotX , rotY , dur , ease){
229
+ // Like `magnet` but also tilts the element in 3D space to face the cursor:
230
+ // translation pulls it toward the pointer while rotationX/rotationY lean it
231
+ // so the face tracks the cursor. `transformOrigin:"50% 50%"` keeps the tilt
232
+ // pivoting around the element's centre and `transformPerspective` gives the
233
+ // rotation its depth (without it, rotationX/rotationY on a flat element look
234
+ // like a subtle skew rather than a real 3D tilt). Passing x:0, y:0, scale:1,
235
+ // rotX:0, rotY:0 resets it back to rest on mouseleave.
236
+ return gsap.to(target , {x , y , scale , rotationX:rotX , rotationY:rotY , transformOrigin:"50% 50%" , transformPerspective:600 , duration:dur , ease:easeOf(ease) , overwrite:"auto"})
237
+ }
238
+
239
+ export function reset (target , dur , ease){
240
+ gsap.to(target, { x: 0, y: 0, scale: 1, duration: dur/2, ease: ease })
241
+ }
242
+
243
+
244
+
245
+
246
+ //Loop animations
247
+
248
+ export function spinCW (target , delay , dur , ease){
249
+ const e = easeOf(ease)
250
+ const tl = gsap.timeline()
251
+ tl.fromTo(target , {scale:1 , rotation:0} , {ease:e , duration:dur , delay:delay , rotation:360})
252
+ .to(target , {scale:1 , duration: delay})
253
+ return tl
254
+ }
255
+ export function spinCCW (target , delay , dur , ease){
256
+ const e = easeOf(ease)
257
+ const tl = gsap.timeline()
258
+ tl.fromTo(target , {scale:1 , rotation:0} , {ease:e , duration:dur , delay:delay , rotation:-360})
259
+ .to(target , {scale:1 , duration: delay})
260
+ return tl
261
+ }
262
+
263
+ export function bounce (delay , target , amount , dur , ease){
264
+ const e = easeOf(ease)
265
+ const tl = gsap.timeline()
266
+ tl.set(target , {transformOrigin : "50% 100%"})
267
+ // A quick, punchy hop: one continuous eased rise to the crest (no
268
+ // intermediate tween to decelerate into, so it can't hang mid-air), then a
269
+ // weighted squash-and-settle on the way down.
270
+ .to(target , {y:-amount - amount * 0.25 , scaleX:0.97 , scaleY:1.03 , duration:dur * 0.48 , ease:"power1.out"})
271
+ .to(target , {y:0 , scaleX:1.08 , scaleY:0.88 , duration:dur * 0.3 , ease:"power2.in"})
272
+ .to(target , {y:0 , scaleX:1 , scaleY:1 , duration:dur * 0.12 , ease:e})
273
+
274
+ .to(target , {x:0 , duration:delay})
275
+
276
+ return tl
277
+ }
278
+
279
+
280
+ export function shake (delay , target , amount , dur , ease){
281
+ const tl = gsap.timeline()
282
+ // Damped oscillation: each swing decays so it feels like real inertia
283
+ // settling, rather than a metronome ticking back and forth.
284
+ tl.to(target , {x:amount , duration:dur * 0.1 , ease:"power2.out"})
285
+ .to(target , {x:-amount * 0.8, duration:dur * 0.15 , ease:"power2.inOut"})
286
+ .to(target , {x:amount * 0.5, duration:dur * 0.2 , ease:"power2.inOut"})
287
+ .to(target , {x:-amount * 0.25, duration:dur * 0.2 , ease:"power2.inOut"})
288
+ .to(target , {x:0 , duration:dur * 0.2 , ease:easeOf(ease)})
289
+ .to(target , {x:0 , duration:delay})
290
+
291
+ return tl
292
+ }
293
+
294
+
295
+ export function bell (delay , target , amount , dur , ease){
296
+ const tl = gsap.timeline()
297
+ tl.set(target , {transformOrigin : "50% 0%"})
298
+ // A quick toll that overshoots and damps down — reads as a physical strike
299
+ // instead of a symmetrical wiggle.
300
+ .to(target , {rotate:amount , duration:dur * 0.12 , ease:"power2.out"})
301
+ .to(target , {rotate:-amount * 0.7, duration:dur * 0.18 , ease:"power2.inOut"})
302
+ .to(target , {rotate:amount * 0.4, duration:dur * 0.22 , ease:"power2.inOut"})
303
+ .to(target , {rotate:-amount * 0.15, duration:dur * 0.22 , ease:"power2.inOut"})
304
+ .to(target , {rotate:0 , duration:dur * 0.2 , ease:easeOf(ease)})
305
+ .to(target , {rotate:0 , duration:delay})
306
+
307
+ return tl
308
+ }
309
+
310
+ export function pulse (delay , target , amount , dur , ease){
311
+ const e = easeOf(ease)
312
+ const tl = gsap.timeline()
313
+ // Overshoot a touch past the target then fall back, so the pulse has a
314
+ // lively beat rather than a flat up-and-down.
315
+ tl.to(target , {scale:(amount / 10) * 1.05 , duration:dur * 0.35 , ease:"power2.out"})
316
+ .to(target , {scale:1 , duration:dur * 0.45 , ease:e})
317
+
318
+ .to(target , {scale:1 , duration:delay})
319
+
320
+
321
+ return tl
322
+ }
323
+
324
+ export function radiate (delay , target , amount , dur , ease , zIndex){
325
+ const clone = target.cloneNode(true)
326
+ 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
+ // Keep the ripple glued to the target so it tracks scroll/resize instead of
328
+ // getting stranded at the position captured when the animation was built.
329
+ // Reposition on scroll/resize (throttled to one pass per frame) rather than
330
+ // reading the rect on EVERY tick: the latter forces a synchronous reflow per
331
+ // frame, a layout-thrash Firefox pays for far more heavily than Chromium.
332
+ let tick = false
333
+ const applyRect = () => {
334
+ const r = target.getBoundingClientRect()
335
+ clone.style.left = `${r.left}px`
336
+ clone.style.top = `${r.top}px`
337
+ clone.style.width = `${r.width}px`
338
+ clone.style.height = `${r.height}px`
339
+ }
340
+ const schedule = () => {
341
+ if (tick) return
342
+ tick = true
343
+ requestAnimationFrame(() => { tick = false; applyRect() })
344
+ }
345
+ document.body.appendChild(clone)
346
+ applyRect()
347
+ window.addEventListener("scroll", schedule, { passive: true })
348
+ window.addEventListener("resize", schedule, { passive: true })
349
+
350
+ return gsap.fromTo(clone , {scale:1 , opacity:1} , {
351
+ scale:amount / 10 ,
352
+ opacity:0 ,
353
+ duration:dur ,
354
+ delay:delay ,
355
+ ease:easeOf(ease) ,
356
+ onComplete: () => {
357
+ clone.remove()
358
+ window.removeEventListener("scroll", schedule)
359
+ window.removeEventListener("resize", schedule)
360
+ } ,
361
+ })
362
+ }
363
+
364
+
365
+ export function hover (delay , target , amount , dur , ease){
366
+ const tl = gsap.timeline()
367
+ // Smooth, symmetric drift up and back down reads as floating rather than
368
+ // bouncing. A gentle sine ease plus a soft scale breathing keeps it alive.
369
+ tl.set(target , {transformOrigin : "50% 50%"})
370
+ .to(target , {y:-amount , scaleX:0.98 , scaleY:1.02 , duration:dur/2 , ease:"sine.inOut"})
371
+ .to(target , {y:0 , scaleX:1 , scaleY:1 , duration:dur/2 , ease:"sine.inOut"})
372
+
373
+ return tl
374
+ }
375
+
376
+
377
+ export function marquee (target , dir , duration , xOffset = 0 , yOffset = 0 , noRepeat = false){
378
+ const horizontal = dir === "left" || dir === "right"
379
+ // Anchor the track to the top-left corner so its two identical copies tile
380
+ // the container exactly. The track is positioned absolutely, out of the
381
+ // container's flex layout — otherwise a `justify-center` (or any alignment)
382
+ // on the container centers the overflowing track and shifts the tile seam,
383
+ // which opens a gap on the trailing edge at some point in the loop.
384
+ target.style.position = "relative"
385
+ target.style.overflow = "hidden"
386
+ const track = document.createElement("div")
387
+ track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
388
+ while (target.firstChild) track.appendChild(target.firstChild)
389
+ target.appendChild(track)
390
+
391
+ // The track is absolutely positioned, so once its content moves in, the host
392
+ // has no in-flow children left and can collapse to zero height. With the
393
+ // `overflow:hidden` set above that clips the track away entirely (e.g. a
394
+ // bare-text `<h1 class="marquee-left">` disappears). Preserve the content's
395
+ // height on the host when that happens so the marquee stays visible. Hosts
396
+ // with their own height (flex cards etc.) are left untouched.
397
+ if (target.offsetHeight === 0 && track.offsetHeight > 0) {
398
+ target.style.height = track.offsetHeight + "px"
399
+ }
400
+
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
+ // Default: repeat the unit until the whole strip is at least as wide as the
409
+ // viewport (plus one extra copy so the trailing edge stays covered mid-loop).
410
+ // `.marquee-no-repeat` opts into the minimal 2-copy single-seam behaviour.
411
+ let copies
412
+ if (noRepeat) {
413
+ copies = 2
414
+ } else {
415
+ const viewport = horizontal ? target.offsetWidth : target.offsetHeight
416
+ copies = Math.max(2, Math.ceil(viewport / unitSize) + 1)
417
+ }
418
+ track.innerHTML = unitHtml.repeat(copies)
419
+
420
+ const dist = unitSize
421
+ const vars = { duration: duration, ease: "none" }
422
+
423
+ if (dir === "right") return gsap.fromTo(track, { x: -dist }, { x: 0, ...vars })
424
+ if (dir === "up") return gsap.fromTo(track, { y: 0 }, { y: -dist, ...vars })
425
+ if (dir === "down") return gsap.fromTo(track, { y: -dist }, { y: 0, ...vars })
426
+ return gsap.fromTo(track, { x: 0 }, { x: -dist, ...vars }) // left
427
+ }
428
+
429
+ //Extras
430
+ export function flip (state , ease , dur){
431
+ Flip.from(state, {duration: dur, ease: ease || DEFAULT_EASE});
432
+ }
433
+
434
+ export function animatecss (target , dur , delay , ease, propertyS , propertySValue , propertyE , propertyEValue) {
435
+ return gsap.fromTo(target , {[propertyS]:propertySValue} , {[propertyE]:propertyEValue , ease:ease, duration:dur, delay:delay})
436
+ }