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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AnimToggle.js CHANGED
@@ -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
@@ -591,7 +591,7 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
591
591
  tick = true
592
592
  requestAnimationFrame(() => { tick = false; applyRect() })
593
593
  }
594
- // Don't animate detached targets — return inert tween
594
+ // Don't animate detached targets - return inert tween
595
595
  if (!target.isConnected) {
596
596
  return gsap.fromTo(clone, {}, { duration: 0 })
597
597
  }
@@ -609,7 +609,7 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
609
609
  if (observer) observer.disconnect()
610
610
  }
611
611
  // If target is removed from DOM (React unmount, .remove(), SPA navigation),
612
- // kill the tween and remove the clone — mirrors React useEffect cleanup
612
+ // kill the tween and remove the clone - mirrors React useEffect cleanup
613
613
  const observer = new MutationObserver(() => {
614
614
  if (!target.isConnected) {
615
615
  if (tween) tween.kill()
package/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to `gclass-anims` will be documented in this file.
4
4
 
5
+ ## [1.0.0-beta.21] - 2026-9-3
6
+ - Added a `gclassOpts()` function that controls the animation fps and observer throttling
7
+
5
8
  ## [1.0.0-beta.20] - 2026-9-2
6
9
  - Fix ESM strict import: `AnimToggle.js` and `Listeners.js` now use `.js` extensions (`Remix`/`Qwik` Node ESM `Cannot find module` fix)
7
10
  - Fix `Lit` shadow DOM `works.txt` `no` → light DOM default (`createRenderRoot(){return this}`) + `initListeners(shadowRoot)` docs
package/Listeners.js CHANGED
@@ -93,7 +93,13 @@ const invokePlay = (config, el, delay, dur, ease) => {
93
93
  }
94
94
  }
95
95
 
96
- export default function initListeners(root = document) {
96
+ export default function initListeners(root = document, throttlePerFrame) {
97
+ // overload: initListeners(1) -> throttle only, root defaults to document
98
+ if (typeof root === 'number') {
99
+ throttlePerFrame = root
100
+ root = document
101
+ }
102
+ throttlePerFrame = Number(throttlePerFrame) || 0 // 0 = no throttling (default)
97
103
  gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
98
104
 
99
105
  // helper to scope queries to root (for boot screen: only boot-up subtree animates during boot)
@@ -1539,38 +1545,28 @@ export default function initListeners(root = document) {
1539
1545
  // stops the observer<->morph feedback loop.
1540
1546
  let flipRoots = new Set()
1541
1547
  let flipPendingRaf = null
1542
- const flipObserver = new MutationObserver((mutations) => {
1543
- for (const mutation of mutations) {
1544
- if (mutation.type !== "childList") continue
1545
- const target = mutation.target
1546
- if (target.nodeType !== 1) continue
1547
- flipRoots.add(target)
1548
- }
1549
- if (!flipPendingRaf) {
1550
- flipPendingRaf = requestAnimationFrame(() => {
1551
- flipPendingRaf = null
1552
- if (!flipRoots.size) return
1553
- // A single frame can register several scopes for the SAME
1554
- // element: removing a `.leave` node re-attaches a fixed ghost
1555
- // to <body>, which adds `body` as a second scope alongside the
1556
- // node's former parent. Running animateFlip per scope re-enters
1557
- // playFlip on the same element, killing the in-flight tween and
1558
- // clearing its transform - snapping the element into place.
1559
- // Dedupe across scopes so each element flips exactly once.
1560
- const toFlip = new Set()
1561
- flipRoots.forEach((scope) => {
1562
- if (!scope) return
1563
- gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
1564
- .forEach((el) => { if (el.isConnected) toFlip.add(el) })
1565
- })
1566
- toFlip.forEach(playFlip)
1567
- // Refresh baselines for any .flip that settled this frame.
1568
- gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
1569
- flipRoots = new Set()
1570
- })
1571
- }
1572
- })
1573
- flipObserver.observe(document.body, { childList: true, subtree: true })
1548
+ const flushFlipRoots = () => {
1549
+ if (!flipRoots.size) return
1550
+ // A single frame can register several scopes for the SAME
1551
+ // element: removing a `.leave` node re-attaches a fixed ghost
1552
+ // to <body>, which adds `body` as a second scope alongside the
1553
+ // node's former parent. Running animateFlip per scope re-enters
1554
+ // playFlip on the same element, killing the in-flight tween and
1555
+ // clearing its transform - snapping the element into place.
1556
+ // Dedupe across scopes so each element flips exactly once.
1557
+ const toFlip = new Set()
1558
+ flipRoots.forEach((scope) => {
1559
+ if (!scope) return
1560
+ gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
1561
+ .forEach((el) => { if (el.isConnected) toFlip.add(el) })
1562
+ })
1563
+ toFlip.forEach(playFlip)
1564
+ // Refresh baselines for any .flip that settled this frame.
1565
+ gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
1566
+ flipRoots = new Set()
1567
+ }
1568
+ let flipObserver = null
1569
+ // flipObserver is created conditionally below (hub vs direct)
1574
1570
 
1575
1571
 
1576
1572
 
@@ -1609,7 +1605,23 @@ export default function initListeners(root = document) {
1609
1605
  }
1610
1606
  }
1611
1607
 
1612
- const appearObserver = new MutationObserver((mutations) => {
1608
+ let appearObserver = null
1609
+ let leaveObserver = null
1610
+ let hubObserver = null
1611
+ let hubRaf = null
1612
+ let hubQueue = []
1613
+ let hubCurrentBatch = null
1614
+ let hubCursor = 0
1615
+
1616
+ const handleFlipBatch = (mutations) => {
1617
+ for (const mutation of mutations) {
1618
+ if (mutation.type !== "childList") continue
1619
+ const target = mutation.target
1620
+ if (target.nodeType !== 1) continue
1621
+ flipRoots.add(target)
1622
+ }
1623
+ }
1624
+ const handleAppearBatch = (mutations) => {
1613
1625
  mutations.forEach((mutation) => {
1614
1626
  mutation.addedNodes.forEach((node) => {
1615
1627
  if (node.nodeType !== 1) return
@@ -1639,20 +1651,77 @@ export default function initListeners(root = document) {
1639
1651
  if (pinned) ScrollTrigger.refresh()
1640
1652
  })
1641
1653
  })
1642
- })
1643
- appearObserver.observe(document.body, { childList: true, subtree: true })
1644
-
1645
- // Capture any .leave elements already present so they can exit later
1646
- qAll(".leave").forEach(captureLeave)
1647
-
1648
- const leaveObserver = new MutationObserver((mutations) => {
1654
+ }
1655
+ const handleLeaveBatch = (mutations) => {
1649
1656
  mutations.forEach((mutation) => {
1650
1657
  if (mutation.type !== "childList") return
1651
1658
  mutation.addedNodes.forEach((n) => collectLeave(n).forEach(captureLeave))
1652
1659
  mutation.removedNodes.forEach((n) => collectLeave(n).forEach(playLeave))
1653
1660
  })
1654
- })
1655
- leaveObserver.observe(document.body, { childList: true, subtree: true })
1661
+ }
1662
+ const hubHandlers = [handleFlipBatch, handleAppearBatch, handleLeaveBatch]
1663
+ const hubDrain = () => {
1664
+ hubRaf = null
1665
+ if (!hubCurrentBatch) {
1666
+ if (!hubQueue.length) return
1667
+ hubCurrentBatch = hubQueue.splice(0, hubQueue.length)
1668
+ hubCursor = 0
1669
+ }
1670
+ const end = Math.min(hubCursor + throttlePerFrame, hubHandlers.length)
1671
+ for (let i = hubCursor; i < end; i++) {
1672
+ hubHandlers[i](hubCurrentBatch)
1673
+ if (hubHandlers[i] === handleFlipBatch) flushFlipRoots()
1674
+ }
1675
+ hubCursor = end
1676
+ if (hubCursor < hubHandlers.length) {
1677
+ hubRaf = requestAnimationFrame(hubDrain)
1678
+ } else {
1679
+ hubCurrentBatch = null
1680
+ hubCursor = 0
1681
+ if (hubQueue.length) hubRaf = requestAnimationFrame(hubDrain)
1682
+ }
1683
+ }
1684
+ const scheduleHub = () => {
1685
+ if (hubRaf) return
1686
+ hubRaf = requestAnimationFrame(hubDrain)
1687
+ }
1688
+
1689
+ if (throttlePerFrame > 0) {
1690
+ hubObserver = new MutationObserver((mutations) => {
1691
+ hubQueue.push(...mutations)
1692
+ scheduleHub()
1693
+ })
1694
+ hubObserver.observe(document.body, { childList: true, subtree: true })
1695
+ } else {
1696
+ flipObserver = new MutationObserver((mutations) => {
1697
+ handleFlipBatch(mutations)
1698
+ if (!flipPendingRaf) {
1699
+ flipPendingRaf = requestAnimationFrame(() => {
1700
+ flipPendingRaf = null
1701
+ flushFlipRoots()
1702
+ })
1703
+ }
1704
+ })
1705
+ flipObserver.observe(document.body, { childList: true, subtree: true })
1706
+
1707
+ appearObserver = new MutationObserver((mutations) => {
1708
+ handleAppearBatch(mutations)
1709
+ })
1710
+ appearObserver.observe(document.body, { childList: true, subtree: true })
1711
+
1712
+ // Capture any .leave elements already present so they can exit later
1713
+ qAll(".leave").forEach(captureLeave)
1714
+
1715
+ leaveObserver = new MutationObserver((mutations) => {
1716
+ handleLeaveBatch(mutations)
1717
+ })
1718
+ leaveObserver.observe(document.body, { childList: true, subtree: true })
1719
+ // leave capture for throttled path is done below after branch
1720
+ }
1721
+ if (throttlePerFrame > 0) {
1722
+ // capture for throttled path (was inside else branch above for non-throttled)
1723
+ qAll(".leave").forEach(captureLeave)
1724
+ }
1656
1725
 
1657
1726
  // Keep the captured position fresh (throttled to one pass per frame)
1658
1727
  let positionTick = false
@@ -1671,9 +1740,12 @@ export default function initListeners(root = document) {
1671
1740
  window.addEventListener("resize", refreshLeavePositions, { passive: true })
1672
1741
 
1673
1742
  return () => {
1674
- appearObserver.disconnect()
1675
- leaveObserver.disconnect()
1676
- flipObserver.disconnect()
1743
+ appearObserver?.disconnect()
1744
+ leaveObserver?.disconnect()
1745
+ flipObserver?.disconnect()
1746
+ if (hubObserver) hubObserver.disconnect()
1747
+ if (hubRaf) cancelAnimationFrame(hubRaf)
1748
+ if (flipPendingRaf) cancelAnimationFrame(flipPendingRaf)
1677
1749
  window.removeEventListener("scroll", refreshLeavePositions)
1678
1750
  window.removeEventListener("resize", refreshLeavePositions)
1679
1751
  window.removeEventListener("load", ScrollTrigger.refresh)
package/index.d.ts CHANGED
@@ -4,8 +4,28 @@
4
4
 
5
5
  // --- AnimToggle ------------------------------------------------------------
6
6
 
7
- /** Boots the GSAP animation system (idempotent). */
8
- export function initAnimations(): void
7
+ export interface GClassConfig {
8
+ throttlePerFrame: number
9
+ fps: number
10
+ }
11
+
12
+ /**
13
+ * Boots the GSAP animation system (idempotent).
14
+ * @param throttlePerFrame - number of observer handlers allowed per rAF frame (0 = no throttling, default).
15
+ * Also accepts initAnimations({throttlePerFrame, fps}) object overload.
16
+ * @param fps - GSAP ticker fps cap (0 = default rAF, e.g. 30 for low-end). 0/undefined = no cap.
17
+ */
18
+ export function initAnimations(throttlePerFrame?: number | GClassConfig, fps?: number): void
19
+ /**
20
+ * Change GClass runtime options on the fly without reload.
21
+ * gclassOpts(throttlePerFrame, fps) — both optional numbers, missing -> defaults (0).
22
+ * gclassOpts() resets to defaults (no throttle, default ticker).
23
+ * Also accepts gclassOpts({throttlePerFrame, fps}).
24
+ * Example low-end button: onClick={() => gclassOpts(1, 30)}
25
+ */
26
+ export function gclassOpts(throttlePerFrame?: number | GClassConfig, fps?: number): GClassConfig
27
+ export function getGClassConfig(): GClassConfig
28
+ export function subscribeGClassConfig(cb: (cfg: GClassConfig) => void): () => void
9
29
  /** Toggle animations on/off and reload the page. */
10
30
  export function toggleAnimations(): void
11
31
  /** Force animations off (wins over any stored preference) and reload. */
@@ -28,8 +48,12 @@ export function registerComplete(name: string, fn: CompleteHandler): CompleteHan
28
48
  * Boot the engine directly, bypassing AnimToggle. Returns a teardown function
29
49
  * that removes all listeners/observers/tweens created by this run.
30
50
  * Pass a root to scope to that subtree (used for .boot-up).
51
+ * @param root - scope root (Document or HTMLElement)
52
+ * @param throttlePerFrame - number of observer handlers allowed per rAF frame.
53
+ * 0/undefined = no throttling (default). 1 = 1 observer per frame round-robin.
54
+ * Overload: initListeners(throttlePerFrame) is also supported (root defaults to document).
31
55
  */
32
- export default function initListeners(root?: Document | HTMLElement): () => void
56
+ export default function initListeners(root?: Document | HTMLElement | number, throttlePerFrame?: number): () => void
33
57
 
34
58
  // --- onComplete config -----------------------------------------------------
35
59
 
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { initAnimations, toggleAnimations, enableReducedMotion, disableReducedMotion } from './AnimToggle.js'
1
+ export { initAnimations, gclassOpts, getGClassConfig, subscribeGClassConfig, toggleAnimations, enableReducedMotion, disableReducedMotion } from './AnimToggle.js'
2
2
  export { default as initListeners, registerComplete } from './Listeners.js'
3
3
  export { customAnims } from './CustomAnims.js'
4
4
  export { defaults, animations, normalize } from './Config.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gclass-anims",
3
- "version": "1.0.0-beta.20",
3
+ "version": "1.0.0-beta.21",
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",