gclass-anims 1.0.0-beta.21 → 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/Listeners.js CHANGED
@@ -1,4 +1,4 @@
1
- import gsap from 'gsap'
1
+ import { gsap } from 'gsap'
2
2
  import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars, stashText, scrambleVars } from './Animations.js'
3
3
  import { customAnims } from './CustomAnims.js'
4
4
  import { defaults, normalize } from './Config.js'
@@ -12,7 +12,94 @@ const TEXT_PREFIX_LEN = TEXT_PREFIX.length
12
12
  // The engine is fully config-driven. All animation definitions live in
13
13
  // Config.js; here we just normalise them into the two internal views the
14
14
  // machinery consumes (spawn/entrance + loop) plus the raw `all` list.
15
- const { all: animAll, spawnConfigs, loopConfigs } = normalize(customAnims)
15
+ // NOTE: normalized inside initListeners per-call (see beta.22) so runtime
16
+ // customAnims.push() before next init is picked up.
17
+
18
+ // --- Breakpoint support (non-conflicting with Tailwind/Bootstrap) -----------
19
+ // defaults.breakpoints = {xs:475, s:640, m:768, l:1024, xl:1280} -> min-width
20
+ // Single-letter s/m/l avoids collision with Tailwind's sm/md/lg. Usage:
21
+ // <div class="spawn-up"> always
22
+ // <div class="m:spawn-up"> from 768px up
23
+ // <div class="l:spawn-up"> from 1024px up
24
+ // <div class="xs:spawn-up"> from 475px up
25
+ // Colon is valid in classList (class="m:spawn-up") and checked via
26
+ // classList.contains - never via unescaped querySelector (":pseudo" would break).
27
+ const bpEntries = Object.entries(defaults.breakpoints || {}).sort((a, b) => a[1] - b[1])
28
+ const bpNames = bpEntries.map(([k]) => k)
29
+ const bpMap = Object.fromEntries(bpEntries)
30
+ const bpPrefixRE = bpNames.length ? new RegExp(`^(${bpNames.join('|')}):(.+)$`) : /^$^/
31
+ const isBreakpointActive = (bp) => {
32
+ if (!bp) return true
33
+ const px = bpMap[bp]
34
+ if (px == null) return true
35
+ if (typeof window === 'undefined' || !window.matchMedia) return true
36
+ return window.matchMedia(`(min-width: ${px}px)`).matches
37
+ }
38
+ const mqForBp = (bp) => `(min-width: ${bpMap[bp]}px)`
39
+ // Does el carry `bp:base` and is that bp currently active? Also handles base without prefix.
40
+ const elementMatchesSel = (el, sel) => {
41
+ const base = sel.slice(1) // ".spawn-up" -> "spawn-up"
42
+ if (el.classList.contains(base)) return true
43
+ for (const bp of bpNames) {
44
+ if (el.classList.contains(`${bp}:${base}`) && isBreakpointActive(bp)) return true
45
+ }
46
+ return false
47
+ }
48
+ const hasGClass = (el, name) => {
49
+ if (el.classList.contains(name)) return true
50
+ for (const bp of bpNames) if (el.classList.contains(`${bp}:${name}`) && isBreakpointActive(bp)) return true
51
+ return false
52
+ }
53
+ // If el has ANY bp:* class, it is breakpoint-gated. When gated, require at least one active variant.
54
+ const isElBreakpointActive = (el) => {
55
+ let hasBp = false
56
+ for (const c of el.classList) {
57
+ const m = c.match(bpPrefixRE)
58
+ if (!m) continue
59
+ hasBp = true
60
+ if (isBreakpointActive(m[1])) return true
61
+ }
62
+ return !hasBp // no bp prefix -> always active
63
+ }
64
+ // Collect all elements matching sel OR its bp variants that are currently active.
65
+ // Uses manual classList scan instead of querySelector(".m\\:spawn-up") to avoid escaping issues.
66
+ const qAllBp = (sel, qAll) => {
67
+ const base = sel.slice(1)
68
+ const all = qAll("body *")
69
+ return all.filter(el => elementMatchesSel(el, sel) || (() => {
70
+ // also handle spawn-text-* where TEXT_PREFIX is retained: sel=".spawn-text-spawn-up" -> base="spawn-text-spawn-up"
71
+ // already covered by elementMatchesSel
72
+ return false
73
+ })())
74
+ }
75
+ // For generic qAll(sel) calls that should be breakpoint-aware, use this wrapper.
76
+ const wrapQAll = (qAll) => (sel) => {
77
+ // if sel is a comma list (orderSelector), split and union
78
+ if (sel.includes(',')) {
79
+ const parts = sel.split(',').map(s => s.trim()).filter(Boolean)
80
+ const set = new Set()
81
+ parts.forEach(p => wrapQAll(qAll)(p).forEach(e => set.add(e)))
82
+ return [...set]
83
+ }
84
+ // "body *" passthrough
85
+ if (sel === "body *") return qAll(sel)
86
+ const hasBpInSel = bpNames.some(bp => sel.includes(`${bp}:`))
87
+ if (hasBpInSel) {
88
+ // sel already contains bp: variant - filter by active
89
+ return qAll("body *").filter(el => sel.split(',').some(s => elementMatchesSel(el, s.trim())))
90
+ }
91
+ // normal sel - include base + active variants
92
+ try {
93
+ const baseEls = qAll(sel)
94
+ // also scan for active variants that wouldn't be found by qAll(sel)
95
+ const variantEls = qAll("body *").filter(el => {
96
+ for (const bp of bpNames) if (el.classList.contains(`${bp}:${sel.slice(1)}`) && isBreakpointActive(bp)) return true
97
+ return false
98
+ })
99
+ const set = new Set([...baseEls, ...variantEls])
100
+ return [...set]
101
+ } catch { return qAll(sel) }
102
+ }
16
103
 
17
104
  // --- Named onComplete handler registry -------------------------------------
18
105
  // `on-<kind>-complete-<name>` classes resolve `<name>` to a function here
@@ -101,6 +188,8 @@ export default function initListeners(root = document, throttlePerFrame) {
101
188
  }
102
189
  throttlePerFrame = Number(throttlePerFrame) || 0 // 0 = no throttling (default)
103
190
  gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
191
+ // Re-normalize per init so runtime customAnims.push() is picked up (beta.22)
192
+ const { all: animAll, spawnConfigs, loopConfigs } = normalize(customAnims)
104
193
 
105
194
  // helper to scope queries to root (for boot screen: only boot-up subtree animates during boot)
106
195
  const qAll = (sel) => {
@@ -113,6 +202,44 @@ export default function initListeners(root = document, throttlePerFrame) {
113
202
  return els
114
203
  } catch { return gsap.utils.toArray(sel) }
115
204
  }
205
+ // breakpoint-aware helpers (s/m/l/xl) - s/m/l avoids Tailwind sm/md/lg collision
206
+ const mm = gsap.matchMedia()
207
+ const breakpointContexts = [] // track mm contexts for teardown
208
+ const getGateBpForSel = (el, sel) => {
209
+ const base = sel.slice(1)
210
+ for (const bp of bpNames) if (el.classList.contains(`${bp}:${base}`)) return bp
211
+ return null
212
+ }
213
+ const getElGateBp = (el) => {
214
+ for (const c of el.classList) {
215
+ const m = c.match(bpPrefixRE)
216
+ if (m) return m[1]
217
+ }
218
+ return null
219
+ }
220
+ const runWithBreakpoint = (el, fn) => {
221
+ const bp = getElGateBp(el)
222
+ if (!bp) { fn(); return }
223
+ const mq = mqForBp(bp)
224
+ const ret = mm.add(mq, fn)
225
+ breakpointContexts.push(ret)
226
+ }
227
+ const runWithBreakpointForSel = (el, sel, fn) => {
228
+ const bp = getGateBpForSel(el, sel)
229
+ if (!bp) { fn(); return }
230
+ const mq = mqForBp(bp)
231
+ const ret = mm.add(mq, fn)
232
+ breakpointContexts.push(ret)
233
+ }
234
+ // All elements that have base OR any bp:base (regardless of active) - for mm registration
235
+ const qAllAllVariants = (sel) => {
236
+ const base = sel.slice(1)
237
+ // include base + any variant
238
+ const all = qAll("body *")
239
+ return all.filter(el => el.classList.contains(base) || bpNames.some(bp => el.classList.contains(`${bp}:${base}`)))
240
+ }
241
+ // Active-only view (used for order calculations that must reflect current viewport)
242
+ const qAllActive = wrapQAll(qAll)
116
243
 
117
244
  const registeredListeners = []
118
245
  const onCompleteTweens = []
@@ -120,13 +247,41 @@ export default function initListeners(root = document, throttlePerFrame) {
120
247
  el.addEventListener(type, fn)
121
248
  registeredListeners.push({ el, type, fn })
122
249
  }
250
+ // --- Breakpoint-aware modifiers (amount-N, time-N, priority-N, etc.) --------
251
+ // Supports `m:amount-20`, `l:time-2`, `xl:ease-bounce` etc.
252
+ // Mobile-first: larger active breakpoint wins over smaller/base.
253
+ // e.g. class="amount-10 m:amount-20 l:amount-30" -> 10@xs/s, 20@m, 30@l/xl
254
+ const getActivePrefixedClass = (el, prefix) => {
255
+ let best = null
256
+ let bestPx = -2
257
+ for (const c of el.classList) {
258
+ let bp = null
259
+ let core = c
260
+ const m = c.match(bpPrefixRE)
261
+ if (m) { bp = m[1]; core = m[2] }
262
+ if (!core.startsWith(prefix)) continue
263
+ if (bp && !isBreakpointActive(bp)) continue
264
+ const px = bp ? bpMap[bp] : -1 // base = -1, xs=475 etc.
265
+ if (px > bestPx) { best = c; bestPx = px; }
266
+ }
267
+ return best
268
+ }
269
+ const extractNumber = (cls, prefix) => {
270
+ const idx = cls.indexOf(prefix)
271
+ if (idx === -1) return NaN
272
+ return Number(cls.slice(idx + prefix.length))
273
+ }
123
274
  const readClassNumber = (el, prefix, fallback) => {
124
- const match = [...el.classList].find(c => c.startsWith(prefix))
125
- return match ? Number(match.slice(prefix.length)) : fallback
275
+ const match = getActivePrefixedClass(el, prefix)
276
+ if (!match) return fallback
277
+ const n = extractNumber(match, prefix)
278
+ return Number.isNaN(n) ? fallback : n
126
279
  }
127
280
  const getEase = (el) => {
128
- const match = [...el.classList].find(c => c.startsWith("ease-"))
129
- return match ? match.split("-")[1] : defaults.ease
281
+ const match = getActivePrefixedClass(el, "ease-")
282
+ if (!match) return defaults.ease
283
+ const idx = match.indexOf("ease-")
284
+ return match.slice(idx + "ease-".length) || defaults.ease
130
285
  }
131
286
 
132
287
  // Reduced-motion support. `.reduced` is a per-element opt-out: when the
@@ -134,7 +289,7 @@ export default function initListeners(root = document, throttlePerFrame) {
134
289
  // completely un-animated (its spawn/loop/click/scroll/setup all skip).
135
290
  const reducedMotion = () =>
136
291
  (typeof window !== "undefined" && window.matchMedia?.(`(prefers-reduced-motion: reduce)`)?.matches) ?? false
137
- const isReduced = (el) => reducedMotion() && el.classList.contains("reduced")
292
+ const isReduced = (el) => reducedMotion() && hasGClass(el, "reduced")
138
293
 
139
294
  // `.preserve` keeps an already-rendered element (e.g. one that persists
140
295
  // in a shared layout across route changes) from being re-animated when
@@ -177,11 +332,20 @@ export default function initListeners(root = document, throttlePerFrame) {
177
332
  // Leave animations derive from spawnConfigs so adding an entry here
178
333
  // automatically enables its leave/exit reverse too (single source of truth).
179
334
  const findSpawn = (el) => {
180
- const direct = spawnConfigs.find(({ sel }) => el.matches?.(sel))
335
+ const direct = spawnConfigs.find(({ sel }) => elementMatchesSel(el, sel))
181
336
  if (direct) return direct
182
- const cls = [...el.classList].find(c => c.startsWith(TEXT_PREFIX))
183
- if (!cls) return null
184
- return spawnConfigs.find(({ sel }) => sel === "." + cls.slice(TEXT_PREFIX_LEN))
337
+ // handle TEXT_PREFIX with optional bp: prefix (e.g. "m:spawn-text-spawn-up")
338
+ for (const c of el.classList) {
339
+ let base = c
340
+ let bp = null
341
+ const m = c.match(bpPrefixRE)
342
+ if (m) { bp = m[1]; base = m[2] }
343
+ if (!base.startsWith(TEXT_PREFIX)) continue
344
+ if (bp && !isBreakpointActive(bp)) continue
345
+ const found = spawnConfigs.find(({ sel }) => sel === "." + base.slice(TEXT_PREFIX_LEN))
346
+ if (found) return found
347
+ }
348
+ return null
185
349
  }
186
350
 
187
351
  const isGhost = (el) => el?.dataset?.gsapGhost === "1"
@@ -196,7 +360,7 @@ export default function initListeners(root = document, throttlePerFrame) {
196
360
  const leaveStates = new WeakMap()
197
361
 
198
362
  const captureLeave = (node) => {
199
- if (!node.classList?.contains("leave")) return
363
+ if (!hasGClass(node, "leave")) return
200
364
  const config = findSpawn(node)
201
365
  if (!config || config.typewriter) return
202
366
  leaveStates.set(node, {
@@ -227,7 +391,7 @@ export default function initListeners(root = document, throttlePerFrame) {
227
391
  // Capture the element's first (resting) bounds. A later layout change
228
392
  // morphs from this snapshot to the live position - a vanilla FLIP.
229
393
  const captureFlip = (node) => {
230
- if (!node.classList?.contains("flip")) return
394
+ if (!hasGClass(node, "flip")) return
231
395
  const config = findSpawn(node)
232
396
  if (!config || config.typewriter) return
233
397
  if (flipping.has(node)) return
@@ -350,8 +514,14 @@ export default function initListeners(root = document, throttlePerFrame) {
350
514
 
351
515
  const collectLeave = (node) => {
352
516
  if (!node || node.nodeType !== 1) return []
353
- if (node.classList?.contains("leave")) return [node]
354
- return gsap.utils.toArray(node.querySelectorAll?.(".leave"))
517
+ if (node.classList && hasGClass(node, "leave")) return [node]
518
+ // also consider bp:leave variants - fallback to manual filter
519
+ const found = gsap.utils.toArray(node.querySelectorAll?.(".leave") || [])
520
+ // add bp:leave matches
521
+ qAll("body *").filter(el => hasGClass(el, "leave") && node.contains?.(el) && !found.includes(el)).forEach(el => found.push(el))
522
+ // check for bp:leave on node itself via manual scan if not in found
523
+ if (hasGClass(node, "leave") && !found.includes(node)) found.unshift(node)
524
+ return found
355
525
  }
356
526
 
357
527
  // Refresh the cached rect to the element's RESTING position (after its
@@ -376,14 +546,14 @@ export default function initListeners(root = document, throttlePerFrame) {
376
546
  spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
377
547
 
378
548
  const getOrderDelay = (el, priority) => {
379
- const samepri = qAll(orderSelector())
549
+ const samepri = qAllActive(orderSelector())
380
550
  .filter((e) => {
381
- if (!e.classList.contains("order")) return false
551
+ if (!hasGClass(e, "order")) return false
382
552
  const match = [...e.classList].find(p => p.startsWith("priority-"))
383
553
  return (match ? Number(match.split("-")[1]) : 0) === priority
384
554
  })
385
555
  let order = samepri.indexOf(el)
386
- if (el.classList.contains("reverse")) {
556
+ if (hasGClass(el, "reverse")) {
387
557
  order = samepri.length - 1 - order
388
558
  }
389
559
  return order / defaults.orderDivide
@@ -392,7 +562,7 @@ export default function initListeners(root = document, throttlePerFrame) {
392
562
  const readTiming = (el) => {
393
563
  const priority = readClassNumber(el, "priority-", 0)
394
564
  return {
395
- delay: el.classList.contains("order")
565
+ delay: hasGClass(el, "order")
396
566
  ? getOrderDelay(el, priority)
397
567
  : priority * defaults.spawnDelayMultiplier,
398
568
  duration: readClassNumber(el, "time-", 1),
@@ -465,9 +635,9 @@ export default function initListeners(root = document, throttlePerFrame) {
465
635
  // default). Default (no class) is per-WORD: far fewer split nodes, so
466
636
  // the per-part spawn is much cheaper to animate and paint.
467
637
  const getGranularity = (el) => {
468
- if (el.classList.contains("lines")) return "lines"
469
- if (el.classList.contains("words")) return "words"
470
- if (el.classList.contains("letter")) return "chars"
638
+ if (hasGClass(el, "lines")) return "lines"
639
+ if (hasGClass(el, "words")) return "words"
640
+ if (hasGClass(el, "letter")) return "chars"
471
641
  return "words"
472
642
  }
473
643
  // Cursive RTL scripts (Arabic/Persian) render each letter as a distinct
@@ -673,7 +843,7 @@ export default function initListeners(root = document, throttlePerFrame) {
673
843
  // finishes. Unless the author opts out with `.no-revert`, this frees the
674
844
  // hundreds of per-letter elements so the browser stops reflowing them.
675
845
  const revertSplit = (el) => {
676
- if (el.classList.contains("no-revert")) return
846
+ if (hasGClass(el, "no-revert")) return
677
847
  const s = splitCache.get(el)
678
848
  if (!s) return
679
849
  splitCache.delete(el)
@@ -722,7 +892,7 @@ export default function initListeners(root = document, throttlePerFrame) {
722
892
  return gsap.fromTo(parts, effFrom, {
723
893
  ...computeTo(effFrom), ease, duration, delay, stagger,
724
894
  onComplete: () => {
725
- if (el.classList.contains("leave")) refreshLeaveRect(el)
895
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
726
896
  revertSplit(el)
727
897
  fireOnComplete(el, "spawn")
728
898
  },
@@ -758,7 +928,7 @@ export default function initListeners(root = document, throttlePerFrame) {
758
928
  // classes are present (N = how many % into view to engage, and how far
759
929
  // out of view to release), else the full `top top` -> `bottom bottom`.
760
930
  const setupPin = (el) => {
761
- if (!el.classList.contains("pin") || el.dataset.gsapPinned) return
931
+ if (!hasGClass(el, "pin") || el.dataset.gsapPinned) return
762
932
  const clamp = (n) => Math.min(100, Math.max(0, n))
763
933
  const startClass = readClassNumber(el, "progress-start-", null)
764
934
  const endClass = readClassNumber(el, "progress-end-", null)
@@ -780,7 +950,7 @@ export default function initListeners(root = document, throttlePerFrame) {
780
950
  // created before any scroll/scroll-progress trigger measures its position.
781
951
  // Setting them up here (before the trigger pass below) keeps offsets correct
782
952
  // and lets the single ScrollTrigger.refresh() at the end reconcile layout.
783
- qAll(".pin").forEach(setupPin)
953
+ qAllAllVariants(".pin").forEach(el => runWithBreakpointForSel(el, ".pin", () => setupPin(el)))
784
954
 
785
955
  // Scroll-driven extras - class-driven ScrollTrigger behaviours that don't
786
956
  // fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
@@ -803,9 +973,9 @@ export default function initListeners(root = document, throttlePerFrame) {
803
973
  const cls = [...el.classList]
804
974
  const clamp = (n) => Math.min(100, Math.max(0, n))
805
975
 
806
- const parallaxCls = cls.find((c) => c.startsWith("parallax-"))
976
+ const parallaxCls = getActivePrefixedClass(el, "parallax-")
807
977
  if (parallaxCls) {
808
- const factor = parseFloat(parallaxCls.slice("parallax-".length)) || 1
978
+ const factor = parseFloat(parallaxCls.slice(parallaxCls.indexOf("parallax-") + "parallax-".length)) || 1
809
979
  if (factor === 1) return
810
980
  const amt = (factor - 1) * 50
811
981
  const t = gsap.fromTo(el,
@@ -819,13 +989,13 @@ export default function initListeners(root = document, throttlePerFrame) {
819
989
  return
820
990
  }
821
991
 
822
- if (el.classList.contains("progress-bar") || el.classList.contains("scroll-fill")) {
992
+ if (hasGClass(el, "progress-bar") || hasGClass(el, "scroll-fill")) {
823
993
  const startClass = readClassNumber(el, "progress-start-", null)
824
994
  const endClass = readClassNumber(el, "progress-end-", null)
825
995
  // `.progress-reverse` runs the fill in reverse (full -> empty).
826
996
  // GSAP's ScrollTrigger has no `reversed` config; swap the from/to
827
997
  // so the scrub maps in the opposite direction instead.
828
- const reverse = el.classList.contains("progress-reverse")
998
+ const reverse = hasGClass(el, "progress-reverse")
829
999
  const t = gsap.fromTo(el,
830
1000
  { scaleX: reverse ? 1 : 0 },
831
1001
  {
@@ -842,7 +1012,7 @@ export default function initListeners(root = document, throttlePerFrame) {
842
1012
  return
843
1013
  }
844
1014
 
845
- if (el.classList.contains("scroll-fade-bg")) {
1015
+ if (hasGClass(el, "scroll-fade-bg")) {
846
1016
  const t = gsap.fromTo(el,
847
1017
  { backgroundPosition: "0% 0%" },
848
1018
  {
@@ -854,7 +1024,7 @@ export default function initListeners(root = document, throttlePerFrame) {
854
1024
  return
855
1025
  }
856
1026
 
857
- if (el.classList.contains("scroll-horizontal")) {
1027
+ if (hasGClass(el, "scroll-horizontal")) {
858
1028
  const track = el.querySelector(".scroll-track")
859
1029
  if (!track) return
860
1030
  const getAmount = () => track.scrollWidth - el.clientWidth
@@ -873,7 +1043,7 @@ export default function initListeners(root = document, throttlePerFrame) {
873
1043
  scrollTriggers.push(t.scrollTrigger)
874
1044
  }
875
1045
  }
876
- qAll('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
1046
+ qAll("body *").filter(el => hasGClass(el,"progress-bar") || hasGClass(el,"scroll-fill") || hasGClass(el,"scroll-fade-bg") || hasGClass(el,"scroll-horizontal") || [...el.classList].some(c=>c.startsWith("parallax-") || bpNames.some(bp=>c.startsWith(`${bp}:parallax-`)))).forEach(el => setupScrollDriven(el))
877
1047
 
878
1048
  // Scroller resolution: a `.scroll`/`.scroll-progress` element inside a
879
1049
  // `.scroll-frame` container binds its trigger to THAT box instead of the
@@ -899,7 +1069,7 @@ export default function initListeners(root = document, throttlePerFrame) {
899
1069
  if (!config) return
900
1070
  const { from, typewriter: isTypewriter, typewriterSplit, play } = config
901
1071
 
902
- if (el.classList.contains("scroll-progress")) {
1072
+ if (hasGClass(el, "scroll-progress")) {
903
1073
  const ease = isTypewriter
904
1074
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
905
1075
  : getEase(el)
@@ -920,7 +1090,7 @@ export default function initListeners(root = document, throttlePerFrame) {
920
1090
  // `.progress-reverse` runs the scrub in reverse (revealed -> hidden).
921
1091
  // GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
922
1092
  // the scrub maps in the opposite direction instead.
923
- const reverse = el.classList.contains("progress-reverse")
1093
+ const reverse = hasGClass(el, "progress-reverse")
924
1094
  // .randomize-* applies to the HIDDEN start only (non-reverse):
925
1095
  // a scrub's resting end must stay deterministic or the element
926
1096
  // would sit permanently off-pose after being scrolled through.
@@ -966,7 +1136,7 @@ export default function initListeners(root = document, throttlePerFrame) {
966
1136
  } else if (typewriterSplit) {
967
1137
  const parts = getParts(el, getGranularity(el))
968
1138
  if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
969
- } else if (el.classList.contains("fill-svg") && (el.classList.contains("draw") || el.classList.contains("draw-split"))) {
1139
+ } else if (hasGClass(el, "fill-svg") && (hasGClass(el, "draw") || hasGClass(el, "draw-split"))) {
970
1140
  // fill-svg modifier for draw: stroke first, then fill, sequential scrub.
971
1141
  // For scrub the two phases are sequential tweens so scroll maps
972
1142
  // draw → fill. Reverse swaps order so unfill happens before undraw.
@@ -991,7 +1161,7 @@ export default function initListeners(root = document, throttlePerFrame) {
991
1161
  return
992
1162
  }
993
1163
 
994
- if (!el.classList.contains("scroll")) return
1164
+ if (!hasGClass(el, "scroll")) return
995
1165
  const { delay, duration } = readTiming(el)
996
1166
  const ease = isTypewriter
997
1167
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
@@ -1042,7 +1212,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1042
1212
  })
1043
1213
  scrollTriggers.push(st)
1044
1214
  }
1045
- qAll(".scroll, .scroll-progress").forEach(setupScroll)
1215
+ qAll("body *").filter(el => hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")).forEach(el => runWithBreakpoint(el, () => setupScroll(el)))
1046
1216
 
1047
1217
  // SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
1048
1218
  // tween when the element enters the viewport and reverses on exit.
@@ -1050,7 +1220,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1050
1220
  if (isTypewriter || text === false) return
1051
1221
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
1052
1222
 
1053
- qAll(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
1223
+ qAll("body *").filter(el => elementMatchesSel(el, tSel) && hasGClass(el, "scroll") && !hasGClass(el, "scroll-progress")).forEach((el) => {
1224
+ const run = () => {
1054
1225
  if (isReduced(el)) return
1055
1226
  const { delay, duration } = readTiming(el)
1056
1227
  const ease = getEase(el)
@@ -1073,9 +1244,11 @@ export default function initListeners(root = document, throttlePerFrame) {
1073
1244
  onLeave: reverseToStart,
1074
1245
  onLeaveBack: reverseToStart,
1075
1246
  }))
1247
+ }; runWithBreakpoint(el, run)
1076
1248
  })
1077
1249
 
1078
- qAll(tSel + ".scroll-progress").forEach((el) => {
1250
+ qAll("body *").filter(el => elementMatchesSel(el, tSel) && hasGClass(el, "scroll-progress")).forEach((el) => {
1251
+ const run = () => {
1079
1252
  if (isReduced(el)) return
1080
1253
  const ease = getEase(el)
1081
1254
  const parts = getParts(el, getGranularity(el))
@@ -1096,11 +1269,12 @@ export default function initListeners(root = document, throttlePerFrame) {
1096
1269
  // `.progress-reverse` runs the split scrub in reverse; swap from/to.
1097
1270
  // Randomize applies to the hidden (non-reverse) start only, same
1098
1271
  // rule as the element-level scrub above.
1099
- const reverse = el.classList.contains("progress-reverse")
1272
+ const reverse = hasGClass(el, "progress-reverse")
1100
1273
  const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
1101
1274
  tl.fromTo(parts, { ...(reverse ? to : from), ...rnd },
1102
1275
  { ...(rnd ? randomEnds(Object.keys(rnd)) : null), ...(reverse ? from : to), ease })
1103
1276
  scrollTriggers.push(tl.scrollTrigger)
1277
+ }; runWithBreakpoint(el, run)
1104
1278
  })
1105
1279
  })
1106
1280
  ScrollTrigger.refresh()
@@ -1128,14 +1302,18 @@ export default function initListeners(root = document, throttlePerFrame) {
1128
1302
 
1129
1303
  spawnConfigs.forEach((config) => {
1130
1304
  const { sel, typewriter: isTypewriter, typewriterSplit } = config
1131
- qAll(sel).forEach((el) => {
1132
- if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
1305
+ qAllAllVariants(sel).forEach((el) => {
1306
+ const run = () => {
1307
+ if (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")) return
1133
1308
  if (isPreserved(el)) return
1134
1309
  if (isReduced(el)) return
1310
+ // if this sel is gated via bp:sel and not active, elementMatchesSel will have already filtered,
1311
+ // but qAllAllVariants includes inactive; check active before building
1312
+ if (!elementMatchesSel(el, sel)) return
1135
1313
  const { delay, duration } = readTiming(el)
1136
1314
  if (isTypewriter) {
1137
- const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
1138
- const elEase = easeClass ? easeClass.split("-")[1] : "none"
1315
+ const easeClass = getActivePrefixedClass(el, "ease-") || [...el.classList].find(c => c.startsWith("ease-"))
1316
+ const elEase = easeClass ? easeClass.slice(easeClass.indexOf("ease-") + 5) : "none"
1139
1317
  if (typewriterSplit) {
1140
1318
  el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
1141
1319
  } else {
@@ -1145,8 +1323,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1145
1323
  } else {
1146
1324
  el._spawnTween = invokePlay(config, el, delay, duration, getEase(el))
1147
1325
  el._spawnTween.eventCallback("onComplete", () => {
1148
- if (el.classList.contains("leave")) refreshLeaveRect(el)
1149
- if (el.classList.contains("flip")) captureFlip(el)
1326
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
1327
+ if (hasGClass(el, "flip")) captureFlip(el)
1150
1328
  if (isCompatibility(el)) resumeCompatLoops(el)
1151
1329
  scheduleRefresh()
1152
1330
  fireOnComplete(el, "spawn")
@@ -1154,6 +1332,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1154
1332
  }
1155
1333
  markPreserved(el)
1156
1334
  el.dataset.gsapWired = "1"
1335
+ }; runWithBreakpointForSel(el, sel, run)
1157
1336
  })
1158
1337
  })
1159
1338
 
@@ -1163,14 +1342,17 @@ export default function initListeners(root = document, throttlePerFrame) {
1163
1342
  spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
1164
1343
  if (isTypewriter || text === false) return
1165
1344
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
1166
- qAll(tSel).forEach((el) => {
1167
- if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
1345
+ qAllAllVariants(tSel).forEach((el) => {
1346
+ const run = () => {
1347
+ if (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")) return
1168
1348
  if (isPreserved(el)) return
1169
1349
  if (isReduced(el)) return
1350
+ if (!elementMatchesSel(el, tSel)) return
1170
1351
  const { delay, duration } = readTiming(el)
1171
1352
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
1172
1353
  markPreserved(el)
1173
1354
  el.dataset.gsapWired = "1"
1355
+ }; runWithBreakpointForSel(el, tSel, run)
1174
1356
  })
1175
1357
  })
1176
1358
 
@@ -1218,8 +1400,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1218
1400
  }
1219
1401
 
1220
1402
  const setupMagnet = (el) => {
1221
- if (!el.classList.contains("magnet") && !el.classList.contains("magnet3d")) return
1222
- const threeD = el.classList.contains("magnet3d")
1403
+ if (!hasGClass(el, "magnet") && !hasGClass(el, "magnet3d")) return
1404
+ const threeD = hasGClass(el, "magnet3d")
1223
1405
  const duration = readClassNumber(el, "mtime-", 0.4)
1224
1406
  const pull = readClassNumber(el, "amount-", 0.3)
1225
1407
  const grow = readClassNumber(el, "mgrow-", 1.1)
@@ -1244,7 +1426,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1244
1426
  // a hover/click is active we pause every tracked loop tween on the element
1245
1427
  // and resume it once the interaction ends. Loops are only tracked when the
1246
1428
  // `.compatibility` class is present, so nothing else changes behaviour.
1247
- const isCompatibility = (el) => el.classList.contains("compatibility")
1429
+ const isCompatibility = (el) => hasGClass(el, "compatibility")
1248
1430
  const compatLoopsOf = (el) => {
1249
1431
  if (!el._gsapCompatLoops) el._gsapCompatLoops = []
1250
1432
  return el._gsapCompatLoops
@@ -1259,7 +1441,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1259
1441
  const setupClicks = (el) => {
1260
1442
  if (isReduced(el)) return
1261
1443
  setupMagnet(el)
1262
- if (el.classList.contains("click-hover")) {
1444
+ if (hasGClass(el, "click-hover")) {
1263
1445
  const area = wrapTarget(el)
1264
1446
  let touch = false
1265
1447
  const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
@@ -1276,7 +1458,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1276
1458
  touch = true, verticalmove(el, 0, duration, elEase), setTimeout(() => { touch = false }, 0)
1277
1459
  })
1278
1460
  }
1279
- if (el.classList.contains("click-expand")) {
1461
+ if (hasGClass(el, "click-expand")) {
1280
1462
  let touch = false
1281
1463
  const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
1282
1464
  const lift = readClassNumber(el, "amount-", defaults.clickExpandOffset)
@@ -1313,7 +1495,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1313
1495
  if (isReduced(el)) return
1314
1496
  const ctx = readLoopCtx(el)
1315
1497
  loopConfigs.forEach(({ sel, build, key, loop }) => {
1316
- if (el.matches(sel)) {
1498
+ if (elementMatchesSel(el, sel)) {
1317
1499
  el[key]?.kill()
1318
1500
  el[key] = trackCompatLoop(el, build(el, ctx))
1319
1501
  if (loop) el[key].repeat(-1)
@@ -1368,7 +1550,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1368
1550
  // shifts under the cursor. Marquee is skipped (its build restructures the
1369
1551
  // DOM).
1370
1552
  const wrapTarget = (el) => {
1371
- if (!el.classList.contains("wrapdiv")) return el
1553
+ if (!hasGClass(el, "wrapdiv")) return el
1372
1554
  if (el._gsapWrap) return el._gsapWrap
1373
1555
  const area = document.createElement("div")
1374
1556
  el.before(area)
@@ -1383,7 +1565,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1383
1565
  loopConfigs.forEach(({ sel, build, key }) => {
1384
1566
  if (sel.startsWith(".marquee")) return
1385
1567
  const name = sel.slice(1)
1386
- if (el.classList.contains("hover-" + name)) {
1568
+ if (hasGClass(el, "hover-" + name)) {
1387
1569
  const area = wrapTarget(el)
1388
1570
  addListener(area, "mouseenter", () => {
1389
1571
  pauseCompatLoops(el)
@@ -1396,7 +1578,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1396
1578
  el[key] = reset(el, readClassNumber(el, "etime-", defaults.effectDuration), getEase(el))
1397
1579
  resumeCompatLoops(el)
1398
1580
  })
1399
- } else if (el.classList.contains("click-" + name)) {
1581
+ } else if (hasGClass(el, "click-" + name)) {
1400
1582
  const area = wrapTarget(el)
1401
1583
  addListener(area, "mousedown", () => {
1402
1584
  pauseCompatLoops(el)
@@ -1519,7 +1701,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1519
1701
  if (isReduced(el)) return
1520
1702
  const ctx = readLoopCtx(el)
1521
1703
  animAll.forEach((a) => {
1522
- if (a.setup && el.matches?.(a.sel)) {
1704
+ if (a.setup && elementMatchesSel(el, a.sel)) {
1523
1705
  const teardown = a.setup(el, ctx)
1524
1706
  if (typeof teardown === "function") setupTeardowns.push(teardown)
1525
1707
  }
@@ -1571,13 +1753,13 @@ export default function initListeners(root = document, throttlePerFrame) {
1571
1753
 
1572
1754
 
1573
1755
  const animateAppear = (el) => {
1574
- if (!el.classList.contains("appear") || el._appeared) return
1756
+ if (!hasGClass(el, "appear") || el._appeared) return
1575
1757
  // A `.scroll`/`.scroll-progress` element is owned by its ScrollTrigger
1576
1758
  // (see setupScroll); `.appear` must not also fire, or it plays on mount
1577
1759
  // AND again on scroll-enter. Text elements are the exception: their
1578
1760
  // `.scroll` triggers are wired once at init, so a re-added (reset) text
1579
1761
  // element has no trigger to conflict with and must animate via `.appear`.
1580
- if (!isTextElement(el) && (el.classList.contains("scroll") || el.classList.contains("scroll-progress"))) return
1762
+ if (!isTextElement(el) && (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress"))) return
1581
1763
  if (isReduced(el)) return
1582
1764
  el._appeared = true
1583
1765
  const { delay, duration, ease } = readTiming(el)
@@ -1598,8 +1780,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1598
1780
  } else {
1599
1781
  el._spawnTween = invokePlay(config, el, delay, duration, ease)
1600
1782
  el._spawnTween.eventCallback("onComplete", () => {
1601
- if (el.classList.contains("leave")) refreshLeaveRect(el)
1602
- if (el.classList.contains("flip")) captureFlip(el)
1783
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
1784
+ if (hasGClass(el, "flip")) captureFlip(el)
1603
1785
  fireOnComplete(el, "spawn")
1604
1786
  })
1605
1787
  }
@@ -1630,7 +1812,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1630
1812
  els.forEach((el) => {
1631
1813
  // `.appear` is the opt-in gate for dynamically-added
1632
1814
  // elements: without it a newly inserted node is ignored.
1633
- if (!el.classList?.contains("appear")) return
1815
+ if (!hasGClass(el, "appear")) return
1634
1816
  animateAppear(el)
1635
1817
  setupScroll(el)
1636
1818
  setupScrollDriven(el)
@@ -1710,7 +1892,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1710
1892
  appearObserver.observe(document.body, { childList: true, subtree: true })
1711
1893
 
1712
1894
  // Capture any .leave elements already present so they can exit later
1713
- qAll(".leave").forEach(captureLeave)
1895
+ qAll("body *").filter(el => hasGClass(el, "leave")).forEach(captureLeave)
1714
1896
 
1715
1897
  leaveObserver = new MutationObserver((mutations) => {
1716
1898
  handleLeaveBatch(mutations)
@@ -1720,16 +1902,17 @@ export default function initListeners(root = document, throttlePerFrame) {
1720
1902
  }
1721
1903
  if (throttlePerFrame > 0) {
1722
1904
  // capture for throttled path (was inside else branch above for non-throttled)
1723
- qAll(".leave").forEach(captureLeave)
1905
+ qAll("body *").filter(el => hasGClass(el, "leave")).forEach(captureLeave)
1724
1906
  }
1725
1907
 
1726
- // Keep the captured position fresh (throttled to one pass per frame)
1908
+ // Keep the captured position fresh (throttled to one pass per frame) - include bp:leave
1909
+ const qAllLeaves = () => qAll("body *").filter(el => hasGClass(el, "leave"))
1727
1910
  let positionTick = false
1728
1911
  const refreshLeavePositions = () => {
1729
1912
  if (positionTick) return
1730
1913
  positionTick = true
1731
1914
  requestAnimationFrame(() => {
1732
- qAll(".leave").forEach((el) => {
1915
+ qAllLeaves().forEach((el) => {
1733
1916
  const s = leaveStates.get(el)
1734
1917
  if (s) s.rect = el.getBoundingClientRect()
1735
1918
  })
@@ -1738,6 +1921,18 @@ export default function initListeners(root = document, throttlePerFrame) {
1738
1921
  }
1739
1922
  window.addEventListener("scroll", refreshLeavePositions, { passive: true })
1740
1923
  window.addEventListener("resize", refreshLeavePositions, { passive: true })
1924
+ // Breakpoint reactivity: when crossing a breakpoint, modifiers like m:amount-20 or m:time-2
1925
+ // need to be re-evaluated (GSAP tweens built with old values are stale). Each breakpoint
1926
+ // matchMedia entry is naturally handled by runWithBreakpoint/mm.add for gated animations,
1927
+ // but modifier-only changes (e.g. `amount-10 m:amount-30` on same element) require a rebuild.
1928
+ // Listen to all breakpoints and refresh ScrollTrigger + re-evaluate active prefixed classes.
1929
+ const bpMqls = bpEntries.map(([bp, px]) => {
1930
+ const mql = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(`(min-width: ${px}px)`) : null
1931
+ if (!mql) return null
1932
+ const fn = () => ScrollTrigger.refresh()
1933
+ mql.addEventListener?.('change', fn)
1934
+ return { mql, fn }
1935
+ }).filter(Boolean)
1741
1936
 
1742
1937
  return () => {
1743
1938
  appearObserver?.disconnect()
@@ -1748,6 +1943,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1748
1943
  if (flipPendingRaf) cancelAnimationFrame(flipPendingRaf)
1749
1944
  window.removeEventListener("scroll", refreshLeavePositions)
1750
1945
  window.removeEventListener("resize", refreshLeavePositions)
1946
+ bpMqls.forEach(({ mql, fn }) => mql.removeEventListener?.('change', fn))
1947
+ mm.revert()
1751
1948
  window.removeEventListener("load", ScrollTrigger.refresh)
1752
1949
  clearTimeout(refreshTimer)
1753
1950
  scrollTriggers.forEach((t) => {