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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AnimToggle.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import initListeners from './Listeners.js'
2
2
  import { defaults, animations } from './Config.js'
3
- import gsap from 'gsap'
3
+ import { gsap } from 'gsap'
4
4
 
5
5
  // localStorage key controlling whether the GSAP animation system is mounted.
6
6
  const STORAGE_KEY = 'gclass-animations-enabled'
@@ -118,6 +118,57 @@ let bootTimeout = null
118
118
  let bootStyle = null
119
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
120
 
121
+ // Runtime config for gclassOpts — 0 = defaults (no throttle, default GSAP ticker)
122
+ let currentThrottle = 0
123
+ let currentFps = 0
124
+ const configSubscribers = new Set()
125
+ function getConfigSnapshot() { return { throttlePerFrame: currentThrottle, fps: currentFps } }
126
+ function emitConfig() { configSubscribers.forEach(fn => fn(getConfigSnapshot())) }
127
+ function applyFps(fps) {
128
+ const v = Number(fps) || 0
129
+ currentFps = v
130
+ if (v > 0) gsap.ticker.fps(v)
131
+ else gsap.ticker.fps(0) // 0 = remove cap, fallback to rAF (GSAP default)
132
+ emitConfig()
133
+ }
134
+ function normalizeGclassArgs(throttlePerFrame, fps) {
135
+ // support gclassOpts({throttlePerFrame, fps}) object overload
136
+ if (typeof throttlePerFrame === 'object' && throttlePerFrame !== null) {
137
+ fps = throttlePerFrame.fps
138
+ throttlePerFrame = throttlePerFrame.throttlePerFrame
139
+ }
140
+ const nextThrottle = throttlePerFrame == null ? 0 : Number(throttlePerFrame) || 0
141
+ const nextFps = fps == null ? 0 : Number(fps) || 0
142
+ return { nextThrottle, nextFps }
143
+ }
144
+
145
+ /**
146
+ * Change GClass runtime options on the fly without reload.
147
+ * gclassOpts(throttlePerFrame, fps) — both optional numbers.
148
+ * gclassOpts() or gclassOpts(undefined, undefined) resets to defaults (no throttle, default ticker).
149
+ * Also accepts gclassOpts({throttlePerFrame, fps}).
150
+ * Example low-end button: onClick={() => gclassOpts(1, 30)}
151
+ * Example reset: onClick={() => gclassOpts()} // 0, 60fps rAF
152
+ */
153
+ export function gclassOpts(throttlePerFrame, fps) {
154
+ const { nextThrottle, nextFps } = normalizeGclassArgs(throttlePerFrame, fps)
155
+ currentThrottle = nextThrottle
156
+ applyFps(nextFps)
157
+ // re-wire observers with new throttle without full boot reload (if already running)
158
+ if (typeof window !== 'undefined' && cleanup && !bootTimeout) {
159
+ try { cleanup() } catch {}
160
+ cleanup = null
161
+ if (getEnabled()) cleanup = initListeners(document, currentThrottle)
162
+ }
163
+ // if boot is in progress we just store for next initAnimations; if not running, next initAnimations will use stored values
164
+ return getConfigSnapshot()
165
+ }
166
+ export function getGClassConfig() { return getConfigSnapshot() }
167
+ export function subscribeGClassConfig(cb) {
168
+ configSubscribers.add(cb)
169
+ return () => configSubscribers.delete(cb)
170
+ }
171
+
121
172
  const readBootTime = (els, fallback) => {
122
173
  let max = null
123
174
  for (const el of els) {
@@ -136,7 +187,28 @@ const readBootTime = (els, fallback) => {
136
187
  // Now also handles boot screen: any HTML/JSX with `.boot-up` anywhere is treated as the boot overlay.
137
188
  // No separate initBoot needed - just call initAnimations().
138
189
  // Boot stops all DOM rendering for defaults.bootTime (overwritten by boot-time-N class).
139
- export function initAnimations() {
190
+ // throttlePerFrame / fps: forwarded to gclassOpts-equivalent runtime.
191
+ // initAnimations(throttlePerFrame, fps) — positional numbers, 0/undefined = defaults
192
+ // initAnimations({throttlePerFrame, fps}) — object overload
193
+ // initAnimations() — uses last gclassOpts values (defaults on first call)
194
+ export function initAnimations(throttlePerFrame, fps) {
195
+ // gclassOpts-style normalization: (throttle, fps) positional or {throttlePerFrame, fps} object
196
+ // no args -> fallback to last gclassOpts values (defaults 0 = no throttle, default ticker)
197
+ let effThrottle = currentThrottle
198
+ let effFps = currentFps
199
+ if (throttlePerFrame !== undefined || fps !== undefined) {
200
+ const { nextThrottle, nextFps } = normalizeGclassArgs(throttlePerFrame, fps)
201
+ effThrottle = nextThrottle
202
+ effFps = nextFps
203
+ currentThrottle = effThrottle
204
+ currentFps = effFps
205
+ if (effFps > 0) gsap.ticker.fps(effFps)
206
+ else gsap.ticker.fps(0)
207
+ emitConfig()
208
+ } else {
209
+ if (effFps > 0) gsap.ticker.fps(effFps)
210
+ else gsap.ticker.fps(0)
211
+ }
140
212
  if (typeof window === 'undefined' || !getEnabled()) return
141
213
  // boot already in progress (first mount in StrictMode) - ignore second mount
142
214
  if (bootTimeout) {
@@ -194,7 +266,7 @@ export function initAnimations() {
194
266
  bootEls.forEach(el => { el.style.visibility = 'visible' })
195
267
 
196
268
  // animations inside boot screen must play while rest of DOM is hidden - init scoped to boot-up
197
- bootCleanup = initListeners(bootEl)
269
+ bootCleanup = initListeners(bootEl, effThrottle)
198
270
 
199
271
  bootTimeout = setTimeout(() => {
200
272
  const bootEndCls = [...bootEl.classList].find(c => c.startsWith('boot-end-'))
@@ -205,7 +277,7 @@ export function initAnimations() {
205
277
  bootStyle?.remove(); bootStyle = null
206
278
  hideBootEls(bootEls)
207
279
  bootTimeout = null
208
- cleanup = initListeners()
280
+ cleanup = initListeners(document, effThrottle)
209
281
  return
210
282
  }
211
283
  const name = bootEndCls.slice('boot-end-'.length) // e.g. spawn-blur
@@ -224,7 +296,7 @@ export function initAnimations() {
224
296
  bootStyle?.remove(); bootStyle = null
225
297
  hideBootEls(bootEls)
226
298
  bootTimeout = null
227
- cleanup = initListeners()
299
+ cleanup = initListeners(document, effThrottle)
228
300
  }
229
301
 
230
302
  if (!cfg || !from) {
@@ -238,5 +310,5 @@ export function initAnimations() {
238
310
  return
239
311
  }
240
312
 
241
- cleanup = initListeners()
313
+ cleanup = initListeners(document, effThrottle)
242
314
  }
package/Animations.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DrawSVGPlugin, Flip, MotionPathPlugin, ScrambleTextPlugin, SplitText, TextPlugin } from "gsap/all";
2
- import gsap from "gsap";
2
+ import { gsap } from "gsap";
3
3
  import { defaults } from './Config.js'
4
4
 
5
5
  gsap.registerPlugin(Flip)
@@ -9,6 +9,44 @@ gsap.registerPlugin(DrawSVGPlugin)
9
9
  gsap.registerPlugin(MotionPathPlugin)
10
10
  gsap.registerPlugin(ScrambleTextPlugin)
11
11
 
12
+ // --- Breakpoint helpers for modifiers (xs/s/m/l/xl) -------------------------
13
+ // Lazy to avoid circular init (Config.js <-> Animations.js)
14
+ const getBpDataMod = () => {
15
+ const entries = Object.entries(defaults.breakpoints || {}).sort((a,b)=>a[1]-b[1])
16
+ const names = entries.map(([k])=>k)
17
+ const map = Object.fromEntries(entries)
18
+ const re = names.length ? new RegExp(`^(${names.join('|')}):(.+)$`) : /^$^/
19
+ return { entries, names, map, re }
20
+ }
21
+ const isBreakpointActiveMod = (bp) => {
22
+ if (!bp) return true
23
+ const { map } = getBpDataMod()
24
+ const px = map[bp]
25
+ if (px == null) return true
26
+ if (typeof window === 'undefined' || !window.matchMedia) return true
27
+ return window.matchMedia(`(min-width: ${px}px)`).matches
28
+ }
29
+ const hasGClassMod = (el, name) => {
30
+ if (el.classList.contains(name)) return true
31
+ const { names } = getBpDataMod()
32
+ for (const bp of names) if (el.classList.contains(`${bp}:${name}`) && isBreakpointActiveMod(bp)) return true
33
+ return false
34
+ }
35
+ const getActivePrefixedClassMod = (el, prefix) => {
36
+ const { names, map, re } = getBpDataMod()
37
+ let best = null, bestPx = -2
38
+ for (const c of el.classList) {
39
+ let bp = null, core = c
40
+ const m = c.match(re)
41
+ if (m) { bp = m[1]; core = m[2] }
42
+ if (!core.startsWith(prefix)) continue
43
+ if (bp && !isBreakpointActiveMod(bp)) continue
44
+ const px = bp ? map[bp] : -1
45
+ if (px > bestPx) { best = c; bestPx = px }
46
+ }
47
+ return best
48
+ }
49
+
12
50
  // A tasteful fallback whenever a call site omits an ease, so the animation
13
51
  // never lapses into the raw "none" look. Callers still override this freely.
14
52
  const DEFAULT_EASE = "power3.out";
@@ -210,9 +248,9 @@ export function countTargetVars (target){
210
248
  const end = match ? parseFloat(match[0]) : 0
211
249
  const decimals = match?.[0].includes(".") ? (match[0].split(".")[1] || "").length : 0
212
250
  // Count FROM `.spawn-num-N` (N = the starting number) up to `end`. When the
213
- // class is absent, fall back to 0.
214
- const startCls = [...target.classList].find(c => c.startsWith("spawn-num-"))
215
- const start = startCls ? parseFloat(startCls.slice("spawn-num-".length)) : 0
251
+ // class is absent, fall back to 0. Supports `m:spawn-num-10` etc. (mobile-first)
252
+ const startCls = getActivePrefixedClassMod(target, "spawn-num-")
253
+ const start = startCls ? parseFloat(startCls.slice(startCls.indexOf("spawn-num-") + "spawn-num-".length)) : 0
216
254
  return target._countTarget = { start, end, decimals }
217
255
  }
218
256
 
@@ -231,12 +269,12 @@ export function countUp (target , delay , dur, ease){
231
269
  // the interior fills. `fill-time-N` / `fill-ease-NAME` override the fill
232
270
  // phase; otherwise the fill takes half of `dur` and reuses the draw ease.
233
271
  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
272
+ const m = getActivePrefixedClassMod(el, "fill-time-")
273
+ return m ? Number(m.slice(m.indexOf("fill-time-") + "fill-time-".length)) : fallback * 0.5
236
274
  }
237
275
  const fillEaseOf = (el , fallbackEase) => {
238
- const m = [...el.classList].find(c => c.startsWith("fill-ease-"))
239
- return m ? m.slice("fill-ease-".length) : fallbackEase
276
+ const m = getActivePrefixedClassMod(el, "fill-ease-")
277
+ return m ? m.slice(m.indexOf("fill-ease-") + "fill-ease-".length) : fallbackEase
240
278
  }
241
279
 
242
280
  // Stroke-draw reveal (strokes only - filled SVGs are deliberately out of
@@ -248,13 +286,13 @@ const fillEaseOf = (el , fallbackEase) => {
248
286
  export function drawsvg (target , delay , dur , ease){
249
287
  const e = easeOf(ease)
250
288
  const first = gsap.utils.toArray(target)[0]
251
- const hasFill = !!first?.classList?.contains("fill-svg")
289
+ const hasFill = !!first && hasGClassMod(first, "fill-svg")
252
290
  if (!hasFill) {
253
291
  return gsap.fromTo(target , {drawSVG:"0%"} , {ease:e , duration:dur , delay:delay , drawSVG:"100%"})
254
292
  }
255
293
  const fillDur = fillTimeOf(first , dur)
256
294
  const fillEase = fillEaseOf(first , ease)
257
- const fillTargets = gsap.utils.toArray(target).filter(el => el.classList.contains("fill-svg"))
295
+ const fillTargets = gsap.utils.toArray(target).filter(el => hasGClassMod(el, "fill-svg"))
258
296
  const tl = gsap.timeline({ delay })
259
297
  // Keep fill invisible while the stroke draws
260
298
  if (fillTargets.length) gsap.set(fillTargets , { fillOpacity: 0 })
@@ -332,7 +370,7 @@ export function splitPaths (paths){
332
370
  export function drawsvgSplit (target , delay , dur , ease){
333
371
  const e = easeOf(ease)
334
372
  const first = gsap.utils.toArray(target)[0]
335
- const hasFill = !!first?.classList?.contains("fill-svg")
373
+ const hasFill = !!first && hasGClassMod(first, "fill-svg")
336
374
  const fillDur = hasFill ? fillTimeOf(first , dur) : 0
337
375
  const fillEase = hasFill ? fillEaseOf(first , ease) : ease
338
376
  const tl = gsap.timeline({ delay })
@@ -397,13 +435,29 @@ export const scrambleSegments = (target) => {
397
435
  // .reveal-delay-N -> revealDelay in seconds (default defaults.revealDelay)
398
436
  // .chars-[...] -> character pool taken verbatim from inside the brackets
399
437
  // (default defaults.characterlist)
438
+ // Supports `m:amount-N`, `l:chars-[...]` etc. (largest active wins)
400
439
  export function scrambleVars (target){
401
440
  const num = (prefix , fallback) => {
402
- const match = [...target.classList].find(c => c.startsWith(prefix))
403
- return match ? Number(match.slice(prefix.length)) : fallback
441
+ const c = getActivePrefixedClassMod(target, prefix)
442
+ if (!c) return fallback
443
+ const idx = c.indexOf(prefix)
444
+ const n = Number(c.slice(idx + prefix.length))
445
+ return Number.isNaN(n) ? fallback : n
404
446
  }
405
447
  // Greedy up to the LAST "]" so pools containing "]" survive intact.
406
- const charsCls = [...target.classList].find(c => /^chars-\[(.*)\]$/.test(c))
448
+ // Check for `m:chars-[...]` then fallback to `chars-[...]`
449
+ let charsCls = null
450
+ let bestPx = -2
451
+ const { re: reChars, map: mapChars } = getBpDataMod()
452
+ for (const c of target.classList) {
453
+ let bp = null, core = c
454
+ const m = c.match(reChars)
455
+ if (m) { bp = m[1]; core = m[2] }
456
+ if (!/^chars-\[(.*)\]$/.test(core)) continue
457
+ if (bp && !isBreakpointActiveMod(bp)) continue
458
+ const px = bp ? mapChars[bp] : -1
459
+ if (px > bestPx) { charsCls = core; bestPx = px }
460
+ }
407
461
  return {
408
462
  segs: scrambleSegments(target) ,
409
463
  chars: charsCls ? charsCls.slice("chars-[".length , -1) : defaults.characterlist ,
@@ -411,7 +465,7 @@ export function scrambleVars (target){
411
465
  revealDelay: num("reveal-delay-" , defaults.revealDelay) ,
412
466
  // .scramble-rtl flips the reveal direction (ScrambleTextPlugin's
413
467
  // rightToLeft) so the sweep travels right -> left.
414
- rtl: target.classList.contains("scramble-rtl") ,
468
+ rtl: hasGClassMod(target, "scramble-rtl") ,
415
469
  }
416
470
  }
417
471
 
@@ -430,9 +484,9 @@ export function scrambleVars (target){
430
484
  // ScrambleTextPlugin resolve).
431
485
  // .scramble-rtl - reveal travels right -> left.
432
486
  export function scramble (target , delay , dur , ease){
433
- const e = [...target.classList].some(c => c.startsWith("ease-")) ? easeOf(ease) : "none"
487
+ const e = getActivePrefixedClassMod(target, "ease-") ? easeOf(ease) : "none"
434
488
  const { segs , chars , speed , revealDelay , rtl } = scrambleVars(target)
435
- const all = target.classList.contains("scramble-all")
489
+ const all = hasGClassMod(target, "scramble-all")
436
490
  const tl = gsap.timeline({ delay })
437
491
  segs.forEach(({ t , text }) => {
438
492
  if (all) {
@@ -591,7 +645,7 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
591
645
  tick = true
592
646
  requestAnimationFrame(() => { tick = false; applyRect() })
593
647
  }
594
- // Don't animate detached targets return inert tween
648
+ // Don't animate detached targets - return inert tween
595
649
  if (!target.isConnected) {
596
650
  return gsap.fromTo(clone, {}, { duration: 0 })
597
651
  }
@@ -609,7 +663,7 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
609
663
  if (observer) observer.disconnect()
610
664
  }
611
665
  // If target is removed from DOM (React unmount, .remove(), SPA navigation),
612
- // kill the tween and remove the clone mirrors React useEffect cleanup
666
+ // kill the tween and remove the clone - mirrors React useEffect cleanup
613
667
  const observer = new MutationObserver(() => {
614
668
  if (!target.isConnected) {
615
669
  if (tween) tween.kill()
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `gclass-anims` will be documented in this file.
4
4
 
5
+ ## [1.0.0-beta.22] - 2026-09-06
6
+ - Added responsive breakpoints — `Config.js:75` `defaults.breakpoints {xs:475,s:640,m:768,l:1024,xl:1280}` (single-letter `s/m/l` avoids Tailwind `sm/md/lg` collision). Usage `m:spawn-up`, `l:float`, `xs:spawn-up`. Gating is live via `gsap.matchMedia` (`Listeners.js:26,202`).
7
+ - Added breakpoint-aware modifiers — `m:amount-20`, `m:time-2`, `m:ease-bounce`, `m:priority-3`, `m:chars-[...]`, `m:spawn-num-10` etc. Mobile-first largest active wins (`Listeners.js:237` `getActivePrefixedClass`, `Animations.js:13` lazy helpers). Covers `amount-`, `time-`, `priority-`, `edelay-`, `etime-`, `stagger-`, `fill-time-`, `reveal-delay-`, `spawn-num-`, `progress-start-`, etc.
8
+ - Fixed runtime `customAnims` re-normalization — `Listeners.js:190` now calls `normalize(customAnims)` inside `initListeners()` so `customAnims.push()` before next `init` is picked up.
9
+ - Added ESM + CJS dual build — `vite.lib.config.js` (Vite lib) builds `dist/gclass.esm.js` + `dist/gclass.cjs` (`gsap` external); `package.json:4` bumped to `beta.22`, `main/module` point to `dist/`, `exports: {import, require}`, `sideEffects:false`, `prepublishOnly: build`.
10
+ - Fixed CJS `gsap` interop — `Animations.js:2`/`Listeners.js:1`/`AnimToggle.js:3`/`CustomAnims.js:1` now `import {gsap} from 'gsap'` (named import) for correct `require('gsap').gsap` interop.
11
+ - Added `dev-react-strict` test harness — `vitest` + `jsdom` + `src/__tests__/breakpoints.test.jsx` (14 tests: gating, modifiers, Tailwind coexistence, StrictMode) + `BreakpointHarness.jsx` visual; `vite.config.js` test config.
12
+
13
+ ## [1.0.0-beta.21] - 2026-9-3
14
+ - Added a `gclassOpts()` function that controls the animation fps and observer throttling
15
+
5
16
  ## [1.0.0-beta.20] - 2026-9-2
6
17
  - Fix ESM strict import: `AnimToggle.js` and `Listeners.js` now use `.js` extensions (`Remix`/`Qwik` Node ESM `Cannot find module` fix)
7
18
  - Fix `Lit` shadow DOM `works.txt` `no` → light DOM default (`createRenderRoot(){return this}`) + `initListeners(shadowRoot)` docs
package/Config.js CHANGED
@@ -72,6 +72,7 @@ export const defaults = {
72
72
  revealDelay:0,
73
73
  characterlist:"AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz",
74
74
  bootTime: 5,
75
+ breakpoints : {xs : 475 , s:640 , m:768 , l:1024 , xl:1280}
75
76
  }
76
77
 
77
78
  export const animations = [
package/CustomAnims.js CHANGED
@@ -1,4 +1,4 @@
1
- import gsap from "gsap";
1
+ import { gsap } from "gsap";
2
2
 
3
3
  // A custom animation is just an entry with the SAME shape as one in
4
4
  // Listeners.js' `spawnConfigs`. Give it: