gclass-anims 1.0.0-beta.21 → 1.0.0-beta.22.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/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,79 @@ 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
+ // Preserves DOM order (critical for .order stagger) and respects root scoping (boot screen).
77
+ const wrapQAll = (qAll) => (sel) => {
78
+ if (sel === "body *") return qAll(sel)
79
+ const parts = sel.split(',').map(s => s.trim()).filter(Boolean)
80
+ // Filter all candidates in DOM order, matching base or active bp: variant via elementMatchesSel
81
+ // This keeps .order sequence top-to-bottom instead of grouping by sel type.
82
+ return qAll("body *").filter(el => parts.some(p => {
83
+ // p is like ".spawn-up" or ".spawn-text-spawn-up"
84
+ // elementMatchesSel handles both base and bp:base when active
85
+ try { return elementMatchesSel(el, p) } catch { return false }
86
+ }))
87
+ }
16
88
 
17
89
  // --- Named onComplete handler registry -------------------------------------
18
90
  // `on-<kind>-complete-<name>` classes resolve `<name>` to a function here
@@ -101,6 +173,8 @@ export default function initListeners(root = document, throttlePerFrame) {
101
173
  }
102
174
  throttlePerFrame = Number(throttlePerFrame) || 0 // 0 = no throttling (default)
103
175
  gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
176
+ // Re-normalize per init so runtime customAnims.push() is picked up (beta.22)
177
+ const { all: animAll, spawnConfigs, loopConfigs } = normalize(customAnims)
104
178
 
105
179
  // helper to scope queries to root (for boot screen: only boot-up subtree animates during boot)
106
180
  const qAll = (sel) => {
@@ -113,6 +187,44 @@ export default function initListeners(root = document, throttlePerFrame) {
113
187
  return els
114
188
  } catch { return gsap.utils.toArray(sel) }
115
189
  }
190
+ // breakpoint-aware helpers (s/m/l/xl) - s/m/l avoids Tailwind sm/md/lg collision
191
+ const mm = gsap.matchMedia()
192
+ const breakpointContexts = [] // track mm contexts for teardown
193
+ const getGateBpForSel = (el, sel) => {
194
+ const base = sel.slice(1)
195
+ for (const bp of bpNames) if (el.classList.contains(`${bp}:${base}`)) return bp
196
+ return null
197
+ }
198
+ const getElGateBp = (el) => {
199
+ for (const c of el.classList) {
200
+ const m = c.match(bpPrefixRE)
201
+ if (m) return m[1]
202
+ }
203
+ return null
204
+ }
205
+ const runWithBreakpoint = (el, fn) => {
206
+ const bp = getElGateBp(el)
207
+ if (!bp) { fn(); return }
208
+ const mq = mqForBp(bp)
209
+ const ret = mm.add(mq, fn)
210
+ breakpointContexts.push(ret)
211
+ }
212
+ const runWithBreakpointForSel = (el, sel, fn) => {
213
+ const bp = getGateBpForSel(el, sel)
214
+ if (!bp) { fn(); return }
215
+ const mq = mqForBp(bp)
216
+ const ret = mm.add(mq, fn)
217
+ breakpointContexts.push(ret)
218
+ }
219
+ // All elements that have base OR any bp:base (regardless of active) - for mm registration
220
+ const qAllAllVariants = (sel) => {
221
+ const base = sel.slice(1)
222
+ // include base + any variant
223
+ const all = qAll("body *")
224
+ return all.filter(el => el.classList.contains(base) || bpNames.some(bp => el.classList.contains(`${bp}:${base}`)))
225
+ }
226
+ // Active-only view (used for order calculations that must reflect current viewport)
227
+ const qAllActive = wrapQAll(qAll)
116
228
 
117
229
  const registeredListeners = []
118
230
  const onCompleteTweens = []
@@ -120,13 +232,41 @@ export default function initListeners(root = document, throttlePerFrame) {
120
232
  el.addEventListener(type, fn)
121
233
  registeredListeners.push({ el, type, fn })
122
234
  }
235
+ // --- Breakpoint-aware modifiers (amount-N, time-N, priority-N, etc.) --------
236
+ // Supports `m:amount-20`, `l:time-2`, `xl:ease-bounce` etc.
237
+ // Mobile-first: larger active breakpoint wins over smaller/base.
238
+ // e.g. class="amount-10 m:amount-20 l:amount-30" -> 10@xs/s, 20@m, 30@l/xl
239
+ const getActivePrefixedClass = (el, prefix) => {
240
+ let best = null
241
+ let bestPx = -2
242
+ for (const c of el.classList) {
243
+ let bp = null
244
+ let core = c
245
+ const m = c.match(bpPrefixRE)
246
+ if (m) { bp = m[1]; core = m[2] }
247
+ if (!core.startsWith(prefix)) continue
248
+ if (bp && !isBreakpointActive(bp)) continue
249
+ const px = bp ? bpMap[bp] : -1 // base = -1, xs=475 etc.
250
+ if (px > bestPx) { best = c; bestPx = px; }
251
+ }
252
+ return best
253
+ }
254
+ const extractNumber = (cls, prefix) => {
255
+ const idx = cls.indexOf(prefix)
256
+ if (idx === -1) return NaN
257
+ return Number(cls.slice(idx + prefix.length))
258
+ }
123
259
  const readClassNumber = (el, prefix, fallback) => {
124
- const match = [...el.classList].find(c => c.startsWith(prefix))
125
- return match ? Number(match.slice(prefix.length)) : fallback
260
+ const match = getActivePrefixedClass(el, prefix)
261
+ if (!match) return fallback
262
+ const n = extractNumber(match, prefix)
263
+ return Number.isNaN(n) ? fallback : n
126
264
  }
127
265
  const getEase = (el) => {
128
- const match = [...el.classList].find(c => c.startsWith("ease-"))
129
- return match ? match.split("-")[1] : defaults.ease
266
+ const match = getActivePrefixedClass(el, "ease-")
267
+ if (!match) return defaults.ease
268
+ const idx = match.indexOf("ease-")
269
+ return match.slice(idx + "ease-".length) || defaults.ease
130
270
  }
131
271
 
132
272
  // Reduced-motion support. `.reduced` is a per-element opt-out: when the
@@ -134,7 +274,7 @@ export default function initListeners(root = document, throttlePerFrame) {
134
274
  // completely un-animated (its spawn/loop/click/scroll/setup all skip).
135
275
  const reducedMotion = () =>
136
276
  (typeof window !== "undefined" && window.matchMedia?.(`(prefers-reduced-motion: reduce)`)?.matches) ?? false
137
- const isReduced = (el) => reducedMotion() && el.classList.contains("reduced")
277
+ const isReduced = (el) => reducedMotion() && hasGClass(el, "reduced")
138
278
 
139
279
  // `.preserve` keeps an already-rendered element (e.g. one that persists
140
280
  // in a shared layout across route changes) from being re-animated when
@@ -177,11 +317,20 @@ export default function initListeners(root = document, throttlePerFrame) {
177
317
  // Leave animations derive from spawnConfigs so adding an entry here
178
318
  // automatically enables its leave/exit reverse too (single source of truth).
179
319
  const findSpawn = (el) => {
180
- const direct = spawnConfigs.find(({ sel }) => el.matches?.(sel))
320
+ const direct = spawnConfigs.find(({ sel }) => elementMatchesSel(el, sel))
181
321
  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))
322
+ // handle TEXT_PREFIX with optional bp: prefix (e.g. "m:spawn-text-spawn-up")
323
+ for (const c of el.classList) {
324
+ let base = c
325
+ let bp = null
326
+ const m = c.match(bpPrefixRE)
327
+ if (m) { bp = m[1]; base = m[2] }
328
+ if (!base.startsWith(TEXT_PREFIX)) continue
329
+ if (bp && !isBreakpointActive(bp)) continue
330
+ const found = spawnConfigs.find(({ sel }) => sel === "." + base.slice(TEXT_PREFIX_LEN))
331
+ if (found) return found
332
+ }
333
+ return null
185
334
  }
186
335
 
187
336
  const isGhost = (el) => el?.dataset?.gsapGhost === "1"
@@ -196,7 +345,7 @@ export default function initListeners(root = document, throttlePerFrame) {
196
345
  const leaveStates = new WeakMap()
197
346
 
198
347
  const captureLeave = (node) => {
199
- if (!node.classList?.contains("leave")) return
348
+ if (!hasGClass(node, "leave")) return
200
349
  const config = findSpawn(node)
201
350
  if (!config || config.typewriter) return
202
351
  leaveStates.set(node, {
@@ -227,7 +376,7 @@ export default function initListeners(root = document, throttlePerFrame) {
227
376
  // Capture the element's first (resting) bounds. A later layout change
228
377
  // morphs from this snapshot to the live position - a vanilla FLIP.
229
378
  const captureFlip = (node) => {
230
- if (!node.classList?.contains("flip")) return
379
+ if (!hasGClass(node, "flip")) return
231
380
  const config = findSpawn(node)
232
381
  if (!config || config.typewriter) return
233
382
  if (flipping.has(node)) return
@@ -350,8 +499,14 @@ export default function initListeners(root = document, throttlePerFrame) {
350
499
 
351
500
  const collectLeave = (node) => {
352
501
  if (!node || node.nodeType !== 1) return []
353
- if (node.classList?.contains("leave")) return [node]
354
- return gsap.utils.toArray(node.querySelectorAll?.(".leave"))
502
+ if (node.classList && hasGClass(node, "leave")) return [node]
503
+ // also consider bp:leave variants - fallback to manual filter
504
+ const found = gsap.utils.toArray(node.querySelectorAll?.(".leave") || [])
505
+ // add bp:leave matches
506
+ qAll("body *").filter(el => hasGClass(el, "leave") && node.contains?.(el) && !found.includes(el)).forEach(el => found.push(el))
507
+ // check for bp:leave on node itself via manual scan if not in found
508
+ if (hasGClass(node, "leave") && !found.includes(node)) found.unshift(node)
509
+ return found
355
510
  }
356
511
 
357
512
  // Refresh the cached rect to the element's RESTING position (after its
@@ -376,14 +531,14 @@ export default function initListeners(root = document, throttlePerFrame) {
376
531
  spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
377
532
 
378
533
  const getOrderDelay = (el, priority) => {
379
- const samepri = qAll(orderSelector())
534
+ const samepri = qAllActive(orderSelector())
380
535
  .filter((e) => {
381
- if (!e.classList.contains("order")) return false
536
+ if (!hasGClass(e, "order")) return false
382
537
  const match = [...e.classList].find(p => p.startsWith("priority-"))
383
538
  return (match ? Number(match.split("-")[1]) : 0) === priority
384
539
  })
385
540
  let order = samepri.indexOf(el)
386
- if (el.classList.contains("reverse")) {
541
+ if (hasGClass(el, "reverse")) {
387
542
  order = samepri.length - 1 - order
388
543
  }
389
544
  return order / defaults.orderDivide
@@ -392,7 +547,7 @@ export default function initListeners(root = document, throttlePerFrame) {
392
547
  const readTiming = (el) => {
393
548
  const priority = readClassNumber(el, "priority-", 0)
394
549
  return {
395
- delay: el.classList.contains("order")
550
+ delay: hasGClass(el, "order")
396
551
  ? getOrderDelay(el, priority)
397
552
  : priority * defaults.spawnDelayMultiplier,
398
553
  duration: readClassNumber(el, "time-", 1),
@@ -465,9 +620,9 @@ export default function initListeners(root = document, throttlePerFrame) {
465
620
  // default). Default (no class) is per-WORD: far fewer split nodes, so
466
621
  // the per-part spawn is much cheaper to animate and paint.
467
622
  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"
623
+ if (hasGClass(el, "lines")) return "lines"
624
+ if (hasGClass(el, "words")) return "words"
625
+ if (hasGClass(el, "letter")) return "chars"
471
626
  return "words"
472
627
  }
473
628
  // Cursive RTL scripts (Arabic/Persian) render each letter as a distinct
@@ -673,7 +828,7 @@ export default function initListeners(root = document, throttlePerFrame) {
673
828
  // finishes. Unless the author opts out with `.no-revert`, this frees the
674
829
  // hundreds of per-letter elements so the browser stops reflowing them.
675
830
  const revertSplit = (el) => {
676
- if (el.classList.contains("no-revert")) return
831
+ if (hasGClass(el, "no-revert")) return
677
832
  const s = splitCache.get(el)
678
833
  if (!s) return
679
834
  splitCache.delete(el)
@@ -722,7 +877,7 @@ export default function initListeners(root = document, throttlePerFrame) {
722
877
  return gsap.fromTo(parts, effFrom, {
723
878
  ...computeTo(effFrom), ease, duration, delay, stagger,
724
879
  onComplete: () => {
725
- if (el.classList.contains("leave")) refreshLeaveRect(el)
880
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
726
881
  revertSplit(el)
727
882
  fireOnComplete(el, "spawn")
728
883
  },
@@ -758,7 +913,7 @@ export default function initListeners(root = document, throttlePerFrame) {
758
913
  // classes are present (N = how many % into view to engage, and how far
759
914
  // out of view to release), else the full `top top` -> `bottom bottom`.
760
915
  const setupPin = (el) => {
761
- if (!el.classList.contains("pin") || el.dataset.gsapPinned) return
916
+ if (!hasGClass(el, "pin") || el.dataset.gsapPinned) return
762
917
  const clamp = (n) => Math.min(100, Math.max(0, n))
763
918
  const startClass = readClassNumber(el, "progress-start-", null)
764
919
  const endClass = readClassNumber(el, "progress-end-", null)
@@ -780,7 +935,7 @@ export default function initListeners(root = document, throttlePerFrame) {
780
935
  // created before any scroll/scroll-progress trigger measures its position.
781
936
  // Setting them up here (before the trigger pass below) keeps offsets correct
782
937
  // and lets the single ScrollTrigger.refresh() at the end reconcile layout.
783
- qAll(".pin").forEach(setupPin)
938
+ qAllAllVariants(".pin").forEach(el => runWithBreakpointForSel(el, ".pin", () => setupPin(el)))
784
939
 
785
940
  // Scroll-driven extras - class-driven ScrollTrigger behaviours that don't
786
941
  // fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
@@ -803,9 +958,9 @@ export default function initListeners(root = document, throttlePerFrame) {
803
958
  const cls = [...el.classList]
804
959
  const clamp = (n) => Math.min(100, Math.max(0, n))
805
960
 
806
- const parallaxCls = cls.find((c) => c.startsWith("parallax-"))
961
+ const parallaxCls = getActivePrefixedClass(el, "parallax-")
807
962
  if (parallaxCls) {
808
- const factor = parseFloat(parallaxCls.slice("parallax-".length)) || 1
963
+ const factor = parseFloat(parallaxCls.slice(parallaxCls.indexOf("parallax-") + "parallax-".length)) || 1
809
964
  if (factor === 1) return
810
965
  const amt = (factor - 1) * 50
811
966
  const t = gsap.fromTo(el,
@@ -819,13 +974,13 @@ export default function initListeners(root = document, throttlePerFrame) {
819
974
  return
820
975
  }
821
976
 
822
- if (el.classList.contains("progress-bar") || el.classList.contains("scroll-fill")) {
977
+ if (hasGClass(el, "progress-bar") || hasGClass(el, "scroll-fill")) {
823
978
  const startClass = readClassNumber(el, "progress-start-", null)
824
979
  const endClass = readClassNumber(el, "progress-end-", null)
825
980
  // `.progress-reverse` runs the fill in reverse (full -> empty).
826
981
  // GSAP's ScrollTrigger has no `reversed` config; swap the from/to
827
982
  // so the scrub maps in the opposite direction instead.
828
- const reverse = el.classList.contains("progress-reverse")
983
+ const reverse = hasGClass(el, "progress-reverse")
829
984
  const t = gsap.fromTo(el,
830
985
  { scaleX: reverse ? 1 : 0 },
831
986
  {
@@ -842,7 +997,7 @@ export default function initListeners(root = document, throttlePerFrame) {
842
997
  return
843
998
  }
844
999
 
845
- if (el.classList.contains("scroll-fade-bg")) {
1000
+ if (hasGClass(el, "scroll-fade-bg")) {
846
1001
  const t = gsap.fromTo(el,
847
1002
  { backgroundPosition: "0% 0%" },
848
1003
  {
@@ -854,7 +1009,7 @@ export default function initListeners(root = document, throttlePerFrame) {
854
1009
  return
855
1010
  }
856
1011
 
857
- if (el.classList.contains("scroll-horizontal")) {
1012
+ if (hasGClass(el, "scroll-horizontal")) {
858
1013
  const track = el.querySelector(".scroll-track")
859
1014
  if (!track) return
860
1015
  const getAmount = () => track.scrollWidth - el.clientWidth
@@ -873,7 +1028,7 @@ export default function initListeners(root = document, throttlePerFrame) {
873
1028
  scrollTriggers.push(t.scrollTrigger)
874
1029
  }
875
1030
  }
876
- qAll('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
1031
+ 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
1032
 
878
1033
  // Scroller resolution: a `.scroll`/`.scroll-progress` element inside a
879
1034
  // `.scroll-frame` container binds its trigger to THAT box instead of the
@@ -899,7 +1054,7 @@ export default function initListeners(root = document, throttlePerFrame) {
899
1054
  if (!config) return
900
1055
  const { from, typewriter: isTypewriter, typewriterSplit, play } = config
901
1056
 
902
- if (el.classList.contains("scroll-progress")) {
1057
+ if (hasGClass(el, "scroll-progress")) {
903
1058
  const ease = isTypewriter
904
1059
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
905
1060
  : getEase(el)
@@ -920,7 +1075,7 @@ export default function initListeners(root = document, throttlePerFrame) {
920
1075
  // `.progress-reverse` runs the scrub in reverse (revealed -> hidden).
921
1076
  // GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
922
1077
  // the scrub maps in the opposite direction instead.
923
- const reverse = el.classList.contains("progress-reverse")
1078
+ const reverse = hasGClass(el, "progress-reverse")
924
1079
  // .randomize-* applies to the HIDDEN start only (non-reverse):
925
1080
  // a scrub's resting end must stay deterministic or the element
926
1081
  // would sit permanently off-pose after being scrolled through.
@@ -966,7 +1121,7 @@ export default function initListeners(root = document, throttlePerFrame) {
966
1121
  } else if (typewriterSplit) {
967
1122
  const parts = getParts(el, getGranularity(el))
968
1123
  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"))) {
1124
+ } else if (hasGClass(el, "fill-svg") && (hasGClass(el, "draw") || hasGClass(el, "draw-split"))) {
970
1125
  // fill-svg modifier for draw: stroke first, then fill, sequential scrub.
971
1126
  // For scrub the two phases are sequential tweens so scroll maps
972
1127
  // draw → fill. Reverse swaps order so unfill happens before undraw.
@@ -991,7 +1146,7 @@ export default function initListeners(root = document, throttlePerFrame) {
991
1146
  return
992
1147
  }
993
1148
 
994
- if (!el.classList.contains("scroll")) return
1149
+ if (!hasGClass(el, "scroll")) return
995
1150
  const { delay, duration } = readTiming(el)
996
1151
  const ease = isTypewriter
997
1152
  ? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
@@ -1042,7 +1197,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1042
1197
  })
1043
1198
  scrollTriggers.push(st)
1044
1199
  }
1045
- qAll(".scroll, .scroll-progress").forEach(setupScroll)
1200
+ qAll("body *").filter(el => hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")).forEach(el => runWithBreakpoint(el, () => setupScroll(el)))
1046
1201
 
1047
1202
  // SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
1048
1203
  // tween when the element enters the viewport and reverses on exit.
@@ -1050,7 +1205,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1050
1205
  if (isTypewriter || text === false) return
1051
1206
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
1052
1207
 
1053
- qAll(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
1208
+ qAll("body *").filter(el => elementMatchesSel(el, tSel) && hasGClass(el, "scroll") && !hasGClass(el, "scroll-progress")).forEach((el) => {
1209
+ const run = () => {
1054
1210
  if (isReduced(el)) return
1055
1211
  const { delay, duration } = readTiming(el)
1056
1212
  const ease = getEase(el)
@@ -1073,9 +1229,11 @@ export default function initListeners(root = document, throttlePerFrame) {
1073
1229
  onLeave: reverseToStart,
1074
1230
  onLeaveBack: reverseToStart,
1075
1231
  }))
1232
+ }; runWithBreakpoint(el, run)
1076
1233
  })
1077
1234
 
1078
- qAll(tSel + ".scroll-progress").forEach((el) => {
1235
+ qAll("body *").filter(el => elementMatchesSel(el, tSel) && hasGClass(el, "scroll-progress")).forEach((el) => {
1236
+ const run = () => {
1079
1237
  if (isReduced(el)) return
1080
1238
  const ease = getEase(el)
1081
1239
  const parts = getParts(el, getGranularity(el))
@@ -1096,11 +1254,12 @@ export default function initListeners(root = document, throttlePerFrame) {
1096
1254
  // `.progress-reverse` runs the split scrub in reverse; swap from/to.
1097
1255
  // Randomize applies to the hidden (non-reverse) start only, same
1098
1256
  // rule as the element-level scrub above.
1099
- const reverse = el.classList.contains("progress-reverse")
1257
+ const reverse = hasGClass(el, "progress-reverse")
1100
1258
  const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
1101
1259
  tl.fromTo(parts, { ...(reverse ? to : from), ...rnd },
1102
1260
  { ...(rnd ? randomEnds(Object.keys(rnd)) : null), ...(reverse ? from : to), ease })
1103
1261
  scrollTriggers.push(tl.scrollTrigger)
1262
+ }; runWithBreakpoint(el, run)
1104
1263
  })
1105
1264
  })
1106
1265
  ScrollTrigger.refresh()
@@ -1128,14 +1287,18 @@ export default function initListeners(root = document, throttlePerFrame) {
1128
1287
 
1129
1288
  spawnConfigs.forEach((config) => {
1130
1289
  const { sel, typewriter: isTypewriter, typewriterSplit } = config
1131
- qAll(sel).forEach((el) => {
1132
- if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
1290
+ qAllAllVariants(sel).forEach((el) => {
1291
+ const run = () => {
1292
+ if (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")) return
1133
1293
  if (isPreserved(el)) return
1134
1294
  if (isReduced(el)) return
1295
+ // if this sel is gated via bp:sel and not active, elementMatchesSel will have already filtered,
1296
+ // but qAllAllVariants includes inactive; check active before building
1297
+ if (!elementMatchesSel(el, sel)) return
1135
1298
  const { delay, duration } = readTiming(el)
1136
1299
  if (isTypewriter) {
1137
- const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
1138
- const elEase = easeClass ? easeClass.split("-")[1] : "none"
1300
+ const easeClass = getActivePrefixedClass(el, "ease-") || [...el.classList].find(c => c.startsWith("ease-"))
1301
+ const elEase = easeClass ? easeClass.slice(easeClass.indexOf("ease-") + 5) : "none"
1139
1302
  if (typewriterSplit) {
1140
1303
  el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
1141
1304
  } else {
@@ -1145,8 +1308,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1145
1308
  } else {
1146
1309
  el._spawnTween = invokePlay(config, el, delay, duration, getEase(el))
1147
1310
  el._spawnTween.eventCallback("onComplete", () => {
1148
- if (el.classList.contains("leave")) refreshLeaveRect(el)
1149
- if (el.classList.contains("flip")) captureFlip(el)
1311
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
1312
+ if (hasGClass(el, "flip")) captureFlip(el)
1150
1313
  if (isCompatibility(el)) resumeCompatLoops(el)
1151
1314
  scheduleRefresh()
1152
1315
  fireOnComplete(el, "spawn")
@@ -1154,6 +1317,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1154
1317
  }
1155
1318
  markPreserved(el)
1156
1319
  el.dataset.gsapWired = "1"
1320
+ }; runWithBreakpointForSel(el, sel, run)
1157
1321
  })
1158
1322
  })
1159
1323
 
@@ -1163,14 +1327,17 @@ export default function initListeners(root = document, throttlePerFrame) {
1163
1327
  spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
1164
1328
  if (isTypewriter || text === false) return
1165
1329
  const tSel = "." + TEXT_PREFIX + sel.slice(1)
1166
- qAll(tSel).forEach((el) => {
1167
- if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
1330
+ qAllAllVariants(tSel).forEach((el) => {
1331
+ const run = () => {
1332
+ if (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress")) return
1168
1333
  if (isPreserved(el)) return
1169
1334
  if (isReduced(el)) return
1335
+ if (!elementMatchesSel(el, tSel)) return
1170
1336
  const { delay, duration } = readTiming(el)
1171
1337
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
1172
1338
  markPreserved(el)
1173
1339
  el.dataset.gsapWired = "1"
1340
+ }; runWithBreakpointForSel(el, tSel, run)
1174
1341
  })
1175
1342
  })
1176
1343
 
@@ -1218,8 +1385,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1218
1385
  }
1219
1386
 
1220
1387
  const setupMagnet = (el) => {
1221
- if (!el.classList.contains("magnet") && !el.classList.contains("magnet3d")) return
1222
- const threeD = el.classList.contains("magnet3d")
1388
+ if (!hasGClass(el, "magnet") && !hasGClass(el, "magnet3d")) return
1389
+ const threeD = hasGClass(el, "magnet3d")
1223
1390
  const duration = readClassNumber(el, "mtime-", 0.4)
1224
1391
  const pull = readClassNumber(el, "amount-", 0.3)
1225
1392
  const grow = readClassNumber(el, "mgrow-", 1.1)
@@ -1244,7 +1411,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1244
1411
  // a hover/click is active we pause every tracked loop tween on the element
1245
1412
  // and resume it once the interaction ends. Loops are only tracked when the
1246
1413
  // `.compatibility` class is present, so nothing else changes behaviour.
1247
- const isCompatibility = (el) => el.classList.contains("compatibility")
1414
+ const isCompatibility = (el) => hasGClass(el, "compatibility")
1248
1415
  const compatLoopsOf = (el) => {
1249
1416
  if (!el._gsapCompatLoops) el._gsapCompatLoops = []
1250
1417
  return el._gsapCompatLoops
@@ -1259,7 +1426,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1259
1426
  const setupClicks = (el) => {
1260
1427
  if (isReduced(el)) return
1261
1428
  setupMagnet(el)
1262
- if (el.classList.contains("click-hover")) {
1429
+ if (hasGClass(el, "click-hover")) {
1263
1430
  const area = wrapTarget(el)
1264
1431
  let touch = false
1265
1432
  const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
@@ -1276,7 +1443,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1276
1443
  touch = true, verticalmove(el, 0, duration, elEase), setTimeout(() => { touch = false }, 0)
1277
1444
  })
1278
1445
  }
1279
- if (el.classList.contains("click-expand")) {
1446
+ if (hasGClass(el, "click-expand")) {
1280
1447
  let touch = false
1281
1448
  const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
1282
1449
  const lift = readClassNumber(el, "amount-", defaults.clickExpandOffset)
@@ -1313,7 +1480,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1313
1480
  if (isReduced(el)) return
1314
1481
  const ctx = readLoopCtx(el)
1315
1482
  loopConfigs.forEach(({ sel, build, key, loop }) => {
1316
- if (el.matches(sel)) {
1483
+ if (elementMatchesSel(el, sel)) {
1317
1484
  el[key]?.kill()
1318
1485
  el[key] = trackCompatLoop(el, build(el, ctx))
1319
1486
  if (loop) el[key].repeat(-1)
@@ -1368,7 +1535,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1368
1535
  // shifts under the cursor. Marquee is skipped (its build restructures the
1369
1536
  // DOM).
1370
1537
  const wrapTarget = (el) => {
1371
- if (!el.classList.contains("wrapdiv")) return el
1538
+ if (!hasGClass(el, "wrapdiv")) return el
1372
1539
  if (el._gsapWrap) return el._gsapWrap
1373
1540
  const area = document.createElement("div")
1374
1541
  el.before(area)
@@ -1383,7 +1550,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1383
1550
  loopConfigs.forEach(({ sel, build, key }) => {
1384
1551
  if (sel.startsWith(".marquee")) return
1385
1552
  const name = sel.slice(1)
1386
- if (el.classList.contains("hover-" + name)) {
1553
+ if (hasGClass(el, "hover-" + name)) {
1387
1554
  const area = wrapTarget(el)
1388
1555
  addListener(area, "mouseenter", () => {
1389
1556
  pauseCompatLoops(el)
@@ -1396,7 +1563,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1396
1563
  el[key] = reset(el, readClassNumber(el, "etime-", defaults.effectDuration), getEase(el))
1397
1564
  resumeCompatLoops(el)
1398
1565
  })
1399
- } else if (el.classList.contains("click-" + name)) {
1566
+ } else if (hasGClass(el, "click-" + name)) {
1400
1567
  const area = wrapTarget(el)
1401
1568
  addListener(area, "mousedown", () => {
1402
1569
  pauseCompatLoops(el)
@@ -1519,7 +1686,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1519
1686
  if (isReduced(el)) return
1520
1687
  const ctx = readLoopCtx(el)
1521
1688
  animAll.forEach((a) => {
1522
- if (a.setup && el.matches?.(a.sel)) {
1689
+ if (a.setup && elementMatchesSel(el, a.sel)) {
1523
1690
  const teardown = a.setup(el, ctx)
1524
1691
  if (typeof teardown === "function") setupTeardowns.push(teardown)
1525
1692
  }
@@ -1571,13 +1738,13 @@ export default function initListeners(root = document, throttlePerFrame) {
1571
1738
 
1572
1739
 
1573
1740
  const animateAppear = (el) => {
1574
- if (!el.classList.contains("appear") || el._appeared) return
1741
+ if (!hasGClass(el, "appear") || el._appeared) return
1575
1742
  // A `.scroll`/`.scroll-progress` element is owned by its ScrollTrigger
1576
1743
  // (see setupScroll); `.appear` must not also fire, or it plays on mount
1577
1744
  // AND again on scroll-enter. Text elements are the exception: their
1578
1745
  // `.scroll` triggers are wired once at init, so a re-added (reset) text
1579
1746
  // 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
1747
+ if (!isTextElement(el) && (hasGClass(el, "scroll") || hasGClass(el, "scroll-progress"))) return
1581
1748
  if (isReduced(el)) return
1582
1749
  el._appeared = true
1583
1750
  const { delay, duration, ease } = readTiming(el)
@@ -1598,8 +1765,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1598
1765
  } else {
1599
1766
  el._spawnTween = invokePlay(config, el, delay, duration, ease)
1600
1767
  el._spawnTween.eventCallback("onComplete", () => {
1601
- if (el.classList.contains("leave")) refreshLeaveRect(el)
1602
- if (el.classList.contains("flip")) captureFlip(el)
1768
+ if (hasGClass(el, "leave")) refreshLeaveRect(el)
1769
+ if (hasGClass(el, "flip")) captureFlip(el)
1603
1770
  fireOnComplete(el, "spawn")
1604
1771
  })
1605
1772
  }
@@ -1630,7 +1797,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1630
1797
  els.forEach((el) => {
1631
1798
  // `.appear` is the opt-in gate for dynamically-added
1632
1799
  // elements: without it a newly inserted node is ignored.
1633
- if (!el.classList?.contains("appear")) return
1800
+ if (!hasGClass(el, "appear")) return
1634
1801
  animateAppear(el)
1635
1802
  setupScroll(el)
1636
1803
  setupScrollDriven(el)
@@ -1710,7 +1877,7 @@ export default function initListeners(root = document, throttlePerFrame) {
1710
1877
  appearObserver.observe(document.body, { childList: true, subtree: true })
1711
1878
 
1712
1879
  // Capture any .leave elements already present so they can exit later
1713
- qAll(".leave").forEach(captureLeave)
1880
+ qAll("body *").filter(el => hasGClass(el, "leave")).forEach(captureLeave)
1714
1881
 
1715
1882
  leaveObserver = new MutationObserver((mutations) => {
1716
1883
  handleLeaveBatch(mutations)
@@ -1720,16 +1887,17 @@ export default function initListeners(root = document, throttlePerFrame) {
1720
1887
  }
1721
1888
  if (throttlePerFrame > 0) {
1722
1889
  // capture for throttled path (was inside else branch above for non-throttled)
1723
- qAll(".leave").forEach(captureLeave)
1890
+ qAll("body *").filter(el => hasGClass(el, "leave")).forEach(captureLeave)
1724
1891
  }
1725
1892
 
1726
- // Keep the captured position fresh (throttled to one pass per frame)
1893
+ // Keep the captured position fresh (throttled to one pass per frame) - include bp:leave
1894
+ const qAllLeaves = () => qAll("body *").filter(el => hasGClass(el, "leave"))
1727
1895
  let positionTick = false
1728
1896
  const refreshLeavePositions = () => {
1729
1897
  if (positionTick) return
1730
1898
  positionTick = true
1731
1899
  requestAnimationFrame(() => {
1732
- qAll(".leave").forEach((el) => {
1900
+ qAllLeaves().forEach((el) => {
1733
1901
  const s = leaveStates.get(el)
1734
1902
  if (s) s.rect = el.getBoundingClientRect()
1735
1903
  })
@@ -1738,6 +1906,18 @@ export default function initListeners(root = document, throttlePerFrame) {
1738
1906
  }
1739
1907
  window.addEventListener("scroll", refreshLeavePositions, { passive: true })
1740
1908
  window.addEventListener("resize", refreshLeavePositions, { passive: true })
1909
+ // Breakpoint reactivity: when crossing a breakpoint, modifiers like m:amount-20 or m:time-2
1910
+ // need to be re-evaluated (GSAP tweens built with old values are stale). Each breakpoint
1911
+ // matchMedia entry is naturally handled by runWithBreakpoint/mm.add for gated animations,
1912
+ // but modifier-only changes (e.g. `amount-10 m:amount-30` on same element) require a rebuild.
1913
+ // Listen to all breakpoints and refresh ScrollTrigger + re-evaluate active prefixed classes.
1914
+ const bpMqls = bpEntries.map(([bp, px]) => {
1915
+ const mql = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(`(min-width: ${px}px)`) : null
1916
+ if (!mql) return null
1917
+ const fn = () => ScrollTrigger.refresh()
1918
+ mql.addEventListener?.('change', fn)
1919
+ return { mql, fn }
1920
+ }).filter(Boolean)
1741
1921
 
1742
1922
  return () => {
1743
1923
  appearObserver?.disconnect()
@@ -1748,6 +1928,8 @@ export default function initListeners(root = document, throttlePerFrame) {
1748
1928
  if (flipPendingRaf) cancelAnimationFrame(flipPendingRaf)
1749
1929
  window.removeEventListener("scroll", refreshLeavePositions)
1750
1930
  window.removeEventListener("resize", refreshLeavePositions)
1931
+ bpMqls.forEach(({ mql, fn }) => mql.removeEventListener?.('change', fn))
1932
+ mm.revert()
1751
1933
  window.removeEventListener("load", ScrollTrigger.refresh)
1752
1934
  clearTimeout(refreshTimer)
1753
1935
  scrollTriggers.forEach((t) => {