use-scroll-animate 1.4.0 → 2.0.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +184 -10
  3. package/README_ja.md +57 -1
  4. package/README_zh.md +68 -1
  5. package/dist/{index.mjs → chunks/core-CH41ekIo.cjs} +366 -393
  6. package/dist/chunks/core-CH41ekIo.cjs.map +1 -0
  7. package/dist/{index.esm.js → chunks/core-FUEi4ncH.js} +351 -393
  8. package/dist/chunks/core-FUEi4ncH.js.map +1 -0
  9. package/dist/chunks/stagger-DabrnrcE.js +84 -0
  10. package/dist/chunks/stagger-DabrnrcE.js.map +1 -0
  11. package/dist/chunks/stagger-XD-0-FQz.cjs +86 -0
  12. package/dist/chunks/stagger-XD-0-FQz.cjs.map +1 -0
  13. package/dist/element.cjs +97 -0
  14. package/dist/element.cjs.map +1 -0
  15. package/dist/{types/types.d.ts → element.d.cts} +87 -10
  16. package/dist/element.d.ts +216 -0
  17. package/dist/element.js +95 -0
  18. package/dist/element.js.map +1 -0
  19. package/dist/element.umd.js +2 -0
  20. package/dist/element.umd.js.map +1 -0
  21. package/dist/index.cjs +241 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/{index.d.mts → index.d.cts} +98 -51
  24. package/dist/index.d.ts +98 -51
  25. package/dist/index.js +105 -1070
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.umd.js +5 -3
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/react.cjs +67 -0
  30. package/dist/react.cjs.map +1 -0
  31. package/dist/react.d.cts +159 -0
  32. package/dist/react.d.ts +159 -0
  33. package/dist/react.js +64 -0
  34. package/dist/react.js.map +1 -0
  35. package/dist/solid.cjs +62 -0
  36. package/dist/solid.cjs.map +1 -0
  37. package/dist/solid.d.cts +245 -0
  38. package/dist/solid.d.ts +245 -0
  39. package/dist/solid.js +58 -0
  40. package/dist/solid.js.map +1 -0
  41. package/dist/svelte.cjs +65 -0
  42. package/dist/svelte.cjs.map +1 -0
  43. package/dist/svelte.d.cts +244 -0
  44. package/dist/svelte.d.ts +244 -0
  45. package/dist/svelte.js +62 -0
  46. package/dist/svelte.js.map +1 -0
  47. package/dist/vue.cjs +61 -0
  48. package/dist/vue.cjs.map +1 -0
  49. package/dist/vue.d.cts +159 -0
  50. package/dist/vue.d.ts +159 -0
  51. package/dist/vue.js +59 -0
  52. package/dist/vue.js.map +1 -0
  53. package/docs/API.md +139 -0
  54. package/docs/deprecations.md +20 -0
  55. package/docs/migration-from-aos.md +67 -0
  56. package/docs/migration-from-gsap-scrolltrigger.md +80 -0
  57. package/package.json +86 -15
  58. package/dist/index.esm.js.map +0 -1
  59. package/dist/index.mjs.map +0 -1
  60. package/dist/types/core.d.ts +0 -41
  61. package/dist/types/index.d.ts +0 -36
  62. package/dist/types/presets.d.ts +0 -15
  63. package/dist/types/react.d.ts +0 -29
  64. package/dist/types/sequence.d.ts +0 -38
  65. package/dist/types/stagger.d.ts +0 -25
  66. package/dist/types/vue.d.ts +0 -28
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  /**
2
4
  * use-scroll-animate - Animation Presets
3
5
  * Defines keyframes for all built-in animation presets
@@ -138,9 +140,8 @@ const PRESETS = {
138
140
  },
139
141
  };
140
142
  function resolvePreset(animation) {
141
- var _a;
142
143
  if (typeof animation === 'string') {
143
- return (_a = PRESETS[animation.trim()]) !== null && _a !== void 0 ? _a : PRESETS['fade-in-up'];
144
+ return PRESETS[animation.trim()] ?? PRESETS['fade-in-up'];
144
145
  }
145
146
  if (Array.isArray(animation)) {
146
147
  const combined = { from: {}, to: {} };
@@ -181,9 +182,8 @@ const EASING_MAP = {
181
182
  'heavy-bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
182
183
  };
183
184
  function resolveEasing(easing) {
184
- var _a;
185
185
  if (typeof easing === 'string') {
186
- return (_a = EASING_MAP[easing]) !== null && _a !== void 0 ? _a : easing;
186
+ return EASING_MAP[easing] ?? easing;
187
187
  }
188
188
  if (Array.isArray(easing)) {
189
189
  return `cubic-bezier(${easing.join(', ')})`;
@@ -217,6 +217,7 @@ const DEFAULT_CONFIG = {
217
217
  disabled: false,
218
218
  root: null,
219
219
  autoUnregister: true,
220
+ defaultEngine: 'auto',
220
221
  };
221
222
  const noop = () => undefined;
222
223
  /* ------------------------------------------------------------------ */
@@ -236,6 +237,23 @@ function prefersReducedMotion() {
236
237
  }
237
238
  return !!reducedMotionQuery && reducedMotionQuery.matches;
238
239
  }
240
+ let scrollTimelineSupported;
241
+ /**
242
+ * Whether the browser can run presets on a native scroll-driven timeline:
243
+ * `CSS.supports('animation-timeline: view()')` plus the `ViewTimeline`
244
+ * constructor used to attach it from JavaScript. Cached. SSR-safe.
245
+ */
246
+ function supportsScrollTimeline() {
247
+ if (scrollTimelineSupported === undefined) {
248
+ scrollTimelineSupported =
249
+ hasDOM() &&
250
+ typeof CSS !== 'undefined' &&
251
+ typeof CSS.supports === 'function' &&
252
+ CSS.supports('animation-timeline: view()') &&
253
+ typeof globalThis.ViewTimeline === 'function';
254
+ }
255
+ return scrollTimelineSupported;
256
+ }
239
257
  /* ------------------------------------------------------------------ */
240
258
  /* Option parsing */
241
259
  /* ------------------------------------------------------------------ */
@@ -252,23 +270,37 @@ function lengthValue(value) {
252
270
  const v = value.trim();
253
271
  return /^-?(\d+\.?\d*|\.\d+)$/.test(v) ? parseFloat(v) : v;
254
272
  }
255
- function parseDataAttributes(el, config) {
256
- var _a;
257
- const dataset = el.dataset || {};
273
+ const DEFAULT_PROGRESS_VAR = '--sa-progress';
274
+ const DEFAULT_VIEW_RANGE = ['entry 0%', 'entry 100%'];
275
+ /** `''` (bare attribute) -> default name; `sa-progress` -> `--sa-progress`. */
276
+ function normalizeVar(name) {
277
+ const v = name.trim();
278
+ if (!v)
279
+ return DEFAULT_PROGRESS_VAR;
280
+ return v.startsWith('--') ? v : `--${v}`;
281
+ }
282
+ /**
283
+ * @internal Read options from string attributes. `get(name)` returns the raw
284
+ * value of the attribute `name` (kebab-case, e.g. `root-margin`), `''` for a
285
+ * bare attribute and `undefined` when absent. Shared by `data-sa-*` and the
286
+ * `<scroll-animate>` element.
287
+ */
288
+ function readOptions(get) {
258
289
  const opts = {};
259
- if (dataset.saAnimation) {
260
- const anim = dataset.saAnimation;
290
+ const anim = get('animation');
291
+ if (anim) {
261
292
  opts.animation = (anim.includes(',') ? anim.split(',').map((s) => s.trim()) : anim.trim());
262
293
  }
263
- opts.duration = num(dataset.saDuration);
264
- opts.delay = num(dataset.saDelay);
265
- if (dataset.saEasing) {
266
- const e = dataset.saEasing.trim();
294
+ opts.duration = num(get('duration'));
295
+ opts.delay = num(get('delay'));
296
+ const easing = get('easing');
297
+ if (easing) {
298
+ const e = easing.trim();
267
299
  if (e.startsWith('[')) {
268
300
  try {
269
301
  opts.easing = JSON.parse(e);
270
302
  }
271
- catch (_b) {
303
+ catch {
272
304
  // Malformed JSON: fall back to the default easing instead of throwing
273
305
  }
274
306
  }
@@ -276,55 +308,95 @@ function parseDataAttributes(el, config) {
276
308
  opts.easing = e;
277
309
  }
278
310
  }
279
- if (dataset.saThreshold) {
280
- const t = dataset.saThreshold;
281
- opts.threshold = t.includes(',')
282
- ? t.split(',').map(parseFloat).filter(Number.isFinite)
283
- : num(t);
311
+ const t = get('threshold');
312
+ if (t) {
313
+ opts.threshold = t.includes(',') ? t.split(',').map(parseFloat).filter(Number.isFinite) : num(t);
314
+ }
315
+ const rootMargin = get('root-margin');
316
+ if (rootMargin)
317
+ opts.rootMargin = rootMargin;
318
+ const repeat = get('repeat');
319
+ if (repeat !== undefined)
320
+ opts.repeat = repeat !== 'false';
321
+ const once = get('once');
322
+ if (once !== undefined)
323
+ opts.once = once !== 'false';
324
+ opts.offset = num(get('offset'));
325
+ opts.stagger = num(get('stagger'));
326
+ const progressVar = get('progress-var');
327
+ if (progressVar !== undefined)
328
+ opts.progressVar = normalizeVar(progressVar);
329
+ const engine = get('engine')?.trim();
330
+ if (engine === 'auto' || engine === 'js' || engine === 'css')
331
+ opts.engine = engine;
332
+ const range = get('view-range');
333
+ if (range) {
334
+ const [start, end] = range.split(',').map((s) => s.trim());
335
+ if (start && end)
336
+ opts.viewRange = [start, end];
337
+ }
338
+ const exit = get('exit');
339
+ if (exit !== undefined) {
340
+ const e = exit.trim();
341
+ opts.exit = e === '' || e === 'true' ? true : e === 'false' ? false : (e.includes(',') ? e.split(',').map((s) => s.trim()) : e);
284
342
  }
285
- if (dataset.saRootMargin)
286
- opts.rootMargin = dataset.saRootMargin;
287
- if (dataset.saRepeat !== undefined)
288
- opts.repeat = dataset.saRepeat !== 'false';
289
- if (dataset.saOnce !== undefined)
290
- opts.once = dataset.saOnce !== 'false';
291
- opts.offset = num(dataset.saOffset);
292
- opts.stagger = num(dataset.saStagger);
293
- if (dataset.saProgress)
294
- opts.progressMode = dataset.saProgress.trim() === 'scroll' ? 'scroll' : 'ratio';
295
- if (dataset.saParallaxX || dataset.saParallaxY || dataset.saParallaxRotate || dataset.saParallaxScale) {
343
+ const progress = get('progress');
344
+ if (progress)
345
+ opts.progressMode = progress.trim() === 'scroll' ? 'scroll' : 'ratio';
346
+ const px = get('parallax-x');
347
+ const py = get('parallax-y');
348
+ const pr = get('parallax-rotate');
349
+ const ps = get('parallax-scale');
350
+ if (px || py || pr || ps) {
296
351
  opts.parallax = {
297
- x: lengthValue(dataset.saParallaxX),
298
- y: lengthValue(dataset.saParallaxY),
299
- rotate: num(dataset.saParallaxRotate),
300
- scale: num(dataset.saParallaxScale),
301
- speed: (_a = num(dataset.saParallaxSpeed)) !== null && _a !== void 0 ? _a : 1,
352
+ x: lengthValue(px),
353
+ y: lengthValue(py),
354
+ rotate: num(pr),
355
+ scale: num(ps),
356
+ speed: num(get('parallax-speed')) ?? 1,
302
357
  };
303
358
  }
304
- return mergeOptions(opts, config);
359
+ // Drop keys that were not set, so they don't override defaults when spread.
360
+ Object.keys(opts).forEach((k) => opts[k] === undefined && delete opts[k]);
361
+ return opts;
362
+ }
363
+ function parseDataAttributes(el, config) {
364
+ return mergeOptions(readOptions((name) => {
365
+ const v = el.getAttribute(`data-sa-${name}`);
366
+ return v === null ? undefined : v;
367
+ }), config);
305
368
  }
306
369
  function mergeOptions(opts, config) {
307
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
308
- const repeat = (_a = opts.repeat) !== null && _a !== void 0 ? _a : config.defaultRepeat;
309
- const threshold = (_b = opts.threshold) !== null && _b !== void 0 ? _b : config.defaultThreshold;
370
+ let engine = opts.engine ?? config.defaultEngine;
371
+ // 'auto' keeps time-based semantics when the element asks for them explicitly.
372
+ if (engine === 'auto' && (opts.duration !== undefined || opts.delay !== undefined || opts.offset !== undefined || (opts.stagger ?? 0) > 0)) {
373
+ engine = 'js';
374
+ }
375
+ const exit = opts.exit ?? false;
376
+ const repeat = opts.repeat ?? (exit ? true : config.defaultRepeat);
377
+ const threshold = opts.threshold ?? config.defaultThreshold;
310
378
  return {
311
- animation: (_c = opts.animation) !== null && _c !== void 0 ? _c : config.defaultAnimation,
312
- duration: (_d = opts.duration) !== null && _d !== void 0 ? _d : config.defaultDuration,
313
- delay: (_e = opts.delay) !== null && _e !== void 0 ? _e : config.defaultDelay,
314
- easing: (_f = opts.easing) !== null && _f !== void 0 ? _f : config.defaultEasing,
379
+ animation: opts.animation ?? config.defaultAnimation,
380
+ duration: opts.duration ?? config.defaultDuration,
381
+ delay: opts.delay ?? config.defaultDelay,
382
+ easing: opts.easing ?? config.defaultEasing,
315
383
  threshold: Array.isArray(threshold) && threshold.length === 0 ? config.defaultThreshold : threshold,
316
- rootMargin: (_g = opts.rootMargin) !== null && _g !== void 0 ? _g : config.defaultRootMargin,
384
+ rootMargin: opts.rootMargin ?? config.defaultRootMargin,
317
385
  repeat,
318
- once: (_h = opts.once) !== null && _h !== void 0 ? _h : (repeat ? false : config.defaultOnce),
319
- offset: (_j = opts.offset) !== null && _j !== void 0 ? _j : config.defaultOffset,
320
- stagger: (_k = opts.stagger) !== null && _k !== void 0 ? _k : 0,
321
- parallax: (_l = opts.parallax) !== null && _l !== void 0 ? _l : {},
322
- onStart: (_m = opts.onStart) !== null && _m !== void 0 ? _m : noop,
323
- onComplete: (_o = opts.onComplete) !== null && _o !== void 0 ? _o : noop,
324
- onEnter: (_p = opts.onEnter) !== null && _p !== void 0 ? _p : noop,
325
- onLeave: (_q = opts.onLeave) !== null && _q !== void 0 ? _q : noop,
326
- onProgress: (_r = opts.onProgress) !== null && _r !== void 0 ? _r : noop,
327
- progressMode: (_s = opts.progressMode) !== null && _s !== void 0 ? _s : 'ratio',
386
+ once: opts.once ?? (repeat ? false : config.defaultOnce),
387
+ offset: opts.offset ?? config.defaultOffset,
388
+ stagger: opts.stagger ?? 0,
389
+ parallax: opts.parallax ?? {},
390
+ onStart: opts.onStart ?? noop,
391
+ onComplete: opts.onComplete ?? noop,
392
+ onEnter: opts.onEnter ?? noop,
393
+ onLeave: opts.onLeave ?? noop,
394
+ onProgress: opts.onProgress ?? noop,
395
+ progressMode: opts.progressMode ?? 'ratio',
396
+ progressVar: opts.progressVar ? normalizeVar(opts.progressVar) : '',
397
+ engine,
398
+ viewRange: opts.viewRange ?? DEFAULT_VIEW_RANGE,
399
+ exit,
328
400
  };
329
401
  }
330
402
  function resolveTargets(target) {
@@ -345,14 +417,13 @@ function resolveTargets(target) {
345
417
  * @internal
346
418
  */
347
419
  function applyOffset(rootMargin, offset) {
348
- var _a, _b, _c;
349
420
  if (!offset)
350
421
  return rootMargin;
351
422
  const parts = (rootMargin || '0px').trim().split(/\s+/);
352
423
  const top = parts[0];
353
- const right = (_a = parts[1]) !== null && _a !== void 0 ? _a : top;
354
- const bottom = (_b = parts[2]) !== null && _b !== void 0 ? _b : top;
355
- const left = (_c = parts[3]) !== null && _c !== void 0 ? _c : right;
424
+ const right = parts[1] ?? top;
425
+ const bottom = parts[2] ?? top;
426
+ const left = parts[3] ?? right;
356
427
  const m = /^(-?\d*\.?\d+)(px)?$/.exec(bottom);
357
428
  const base = m ? parseFloat(m[1]) : 0; // non-px bottoms (e.g. %) cannot be combined; offset wins
358
429
  return `${top} ${right} ${base - offset}px ${left}`;
@@ -361,7 +432,7 @@ function hasParallax(p) {
361
432
  return !!p && Object.keys(p).some((k) => p[k] !== undefined);
362
433
  }
363
434
  function needsProgress(opts) {
364
- return hasParallax(opts.parallax) || opts.onProgress !== noop;
435
+ return hasParallax(opts.parallax) || opts.onProgress !== noop || !!opts.progressVar;
365
436
  }
366
437
  /**
367
438
  * True scroll progress of `el` through the viewport (or `root`): 0 when its top
@@ -390,8 +461,15 @@ function getScrollProgress(el, root) {
390
461
  /* ------------------------------------------------------------------ */
391
462
  /** The animation currently running on an element, so it can be cancelled/replaced. */
392
463
  const running = new WeakMap();
464
+ /** Native engine: the scroll-linked exit animation, next to the entrance in `running`. */
465
+ const exits = new WeakMap();
393
466
  const timers = new WeakMap();
394
467
  function cancelRunning(el) {
468
+ const exitAnim = exits.get(el);
469
+ if (exitAnim) {
470
+ exits.delete(el);
471
+ exitAnim.cancel();
472
+ }
395
473
  const anim = running.get(el);
396
474
  if (anim) {
397
475
  running.delete(el);
@@ -502,7 +580,7 @@ function stopAnimation(el, config = {}) {
502
580
  function motionDisabled(config) {
503
581
  return !!config.disabled || prefersReducedMotion();
504
582
  }
505
- function runAnimation(el, opts, config, staggerIndex = 0) {
583
+ function runAnimation(el, opts, config, staggerIndex = 0, pending) {
506
584
  const { duration, delay, stagger, onStart, onComplete } = opts;
507
585
  const totalDelay = Math.max(0, delay + staggerIndex * stagger);
508
586
  cancelRunning(el);
@@ -518,8 +596,10 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
518
596
  el.classList.remove(config.hiddenClass);
519
597
  el.classList.add(config.visibleClass);
520
598
  onStart(el);
599
+ pending?.add(el);
521
600
  timers.set(el, setTimeout(() => {
522
601
  timers.delete(el);
602
+ pending?.delete(el);
523
603
  onComplete(el);
524
604
  }, duration + totalDelay));
525
605
  return;
@@ -544,7 +624,7 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
544
624
  try {
545
625
  anim = el.animate(built.keyframes, timing);
546
626
  }
547
- catch (_a) {
627
+ catch {
548
628
  // Invalid user easing string (WAAPI throws a TypeError): fall back to 'ease'
549
629
  anim = el.animate(built.keyframes, { ...timing, easing: 'ease' });
550
630
  }
@@ -559,7 +639,7 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
559
639
  try {
560
640
  anim.commitStyles();
561
641
  }
562
- catch (_a) {
642
+ catch {
563
643
  setStyles(el, preset.to);
564
644
  }
565
645
  anim.cancel();
@@ -567,6 +647,69 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
567
647
  onComplete(el);
568
648
  };
569
649
  }
650
+ /**
651
+ * Native engine: attach the preset to a `ViewTimeline` of the element, so the
652
+ * browser drives it from scroll position. Returns `null` when that fails
653
+ * (caller falls back to the JS engine).
654
+ */
655
+ function startNative(el, opts, onFrozen) {
656
+ if (typeof el.animate !== 'function')
657
+ return null;
658
+ const preset = resolvePreset(opts.animation);
659
+ const built = buildAnimation(preset, opts.easing);
660
+ let anim;
661
+ try {
662
+ const timeline = new globalThis.ViewTimeline({ subject: el, axis: 'block' });
663
+ const timing = {
664
+ fill: 'both',
665
+ easing: built.easing,
666
+ timeline,
667
+ rangeStart: opts.viewRange[0],
668
+ rangeEnd: opts.viewRange[1],
669
+ };
670
+ try {
671
+ anim = el.animate(built.keyframes, timing);
672
+ }
673
+ catch {
674
+ anim = el.animate(built.keyframes, { ...timing, easing: 'linear' });
675
+ }
676
+ }
677
+ catch {
678
+ return null;
679
+ }
680
+ running.set(el, anim);
681
+ if (opts.exit) {
682
+ // Reverse keyframes over the `exit` range; `fill: 'forwards'` so it has
683
+ // no effect before the element starts leaving.
684
+ const leave = resolvePreset(opts.exit === true ? opts.animation : opts.exit);
685
+ const out = buildAnimation({ from: leave.to, to: leave.from }, opts.easing);
686
+ try {
687
+ const timeline = new globalThis.ViewTimeline({ subject: el, axis: 'block' });
688
+ const timing = { fill: 'forwards', easing: out.easing, timeline, rangeStart: 'exit 0%', rangeEnd: 'exit 100%' };
689
+ exits.set(el, el.animate(out.keyframes, timing));
690
+ }
691
+ catch {
692
+ // Exit is cosmetic: ignore if unsupported.
693
+ }
694
+ }
695
+ const keep = opts.repeat || !opts.once;
696
+ anim.onfinish = () => {
697
+ // `once`: freeze the end state, so scrolling back up does not reverse it.
698
+ if (!keep && running.get(el) === anim) {
699
+ running.delete(el);
700
+ try {
701
+ anim.commitStyles();
702
+ }
703
+ catch {
704
+ setStyles(el, preset.to);
705
+ }
706
+ anim.cancel();
707
+ onFrozen?.();
708
+ }
709
+ opts.onComplete(el);
710
+ };
711
+ return anim;
712
+ }
570
713
  function applyParallax(el, progress, parallax) {
571
714
  const { x = 0, y = 0, rotate = 0, scale = 1, speed = 1 } = parallax;
572
715
  const p = (progress - 0.5) * 2 * speed;
@@ -582,6 +725,26 @@ function applyParallax(el, progress, parallax) {
582
725
  transform += ` scale(${1 + (scale - 1) * p})`;
583
726
  el.style.transform = transform.trim();
584
727
  }
728
+ /** Play the exit animation (entrance or `exit` preset, reversed), ending hidden. */
729
+ function runExit(el, opts, config) {
730
+ if (config.useClassNames || typeof el.animate !== 'function') {
731
+ hideElement(el, config);
732
+ return;
733
+ }
734
+ cancelRunning(el);
735
+ const preset = resolvePreset(opts.exit === true ? opts.animation : opts.exit);
736
+ const built = buildAnimation({ from: preset.to, to: preset.from }, opts.easing);
737
+ const timing = { duration: opts.duration, easing: built.easing, fill: 'forwards' };
738
+ let anim;
739
+ try {
740
+ anim = el.animate(built.keyframes, timing);
741
+ }
742
+ catch {
743
+ anim = el.animate(built.keyframes, { ...timing, easing: 'ease' });
744
+ }
745
+ // Kept (filling) until the next entrance cancels it.
746
+ running.set(el, anim);
747
+ }
585
748
  function hideElement(el, config) {
586
749
  cancelRunning(el);
587
750
  if (config.useClassNames) {
@@ -622,6 +785,12 @@ function createScrollAnimate(userConfig = {}) {
622
785
  const scrolling = new Set();
623
786
  let frame = 0;
624
787
  let listening = null;
788
+ // Active watch() MutationObservers, disconnected by destroy().
789
+ const watchers = new Set();
790
+ // Elements with a pending class-name completion timer started by this instance.
791
+ const pending = new Set();
792
+ // Elements whose entrance runs on a native scroll-driven timeline.
793
+ const natives = new Set();
625
794
  // Observers are shared between elements with the same root/threshold/rootMargin,
626
795
  // instead of one (or two) IntersectionObservers per element.
627
796
  const pools = new Map();
@@ -635,14 +804,22 @@ function createScrollAnimate(userConfig = {}) {
635
804
  return io;
636
805
  }
637
806
  function teardown(el, restore) {
638
- var _a;
639
807
  const record = registry.get(el);
640
808
  if (!record)
641
809
  return;
642
810
  record.observer.unobserve(el);
643
- (_a = record.progressObserver) === null || _a === void 0 ? void 0 : _a.unobserve(el);
811
+ record.progressObserver?.unobserve(el);
644
812
  registry.delete(el);
645
813
  untrack(el);
814
+ if (record.engine === 'css') {
815
+ // A scroll-linked animation would keep following the scroll: stop it and
816
+ // show the element (unless it finished and is just being released).
817
+ if (restore) {
818
+ natives.delete(el);
819
+ reveal(el, config);
820
+ }
821
+ return;
822
+ }
646
823
  // An element that never animated would otherwise stay invisible forever.
647
824
  if (restore && !record.animated)
648
825
  reveal(el, config);
@@ -656,6 +833,11 @@ function createScrollAnimate(userConfig = {}) {
656
833
  function emitProgress(el, record, progress) {
657
834
  const opts = record.options;
658
835
  opts.onProgress(el, progress);
836
+ if (opts.progressVar) {
837
+ const style = el.style;
838
+ if (style)
839
+ style.setProperty(opts.progressVar, String(+progress.toFixed(4)));
840
+ }
659
841
  if (hasParallax(opts.parallax) && !motionDisabled(config))
660
842
  applyParallax(el, progress, opts.parallax);
661
843
  }
@@ -706,7 +888,6 @@ function createScrollAnimate(userConfig = {}) {
706
888
  function onIntersect(entries) {
707
889
  const staggerCounts = new Map();
708
890
  entries.forEach((entry) => {
709
- var _a;
710
891
  const el = entry.target;
711
892
  const record = registry.get(el);
712
893
  if (!record)
@@ -716,6 +897,29 @@ function createScrollAnimate(userConfig = {}) {
716
897
  teardown(el, false);
717
898
  return;
718
899
  }
900
+ if (record.engine === 'css') {
901
+ // The browser drives the animation; only lifecycle bookkeeping here.
902
+ if (entry.isIntersecting) {
903
+ opts.onEnter(el);
904
+ if (!record.animated) {
905
+ record.animated = true;
906
+ opts.onStart(el);
907
+ }
908
+ if (opts.once && !opts.repeat) {
909
+ record.observer.unobserve(el);
910
+ if (config.autoUnregister && !record.progressObserver) {
911
+ finished.add(el);
912
+ teardown(el, false);
913
+ }
914
+ }
915
+ }
916
+ else {
917
+ opts.onLeave(el);
918
+ if (opts.repeat)
919
+ record.animated = false;
920
+ }
921
+ return;
922
+ }
719
923
  if (entry.isIntersecting) {
720
924
  opts.onEnter(el);
721
925
  if (record.animated && !opts.repeat)
@@ -725,10 +929,10 @@ function createScrollAnimate(userConfig = {}) {
725
929
  let staggerIndex = 0;
726
930
  if (opts.stagger > 0) {
727
931
  const parent = el.parentElement;
728
- staggerIndex = (_a = staggerCounts.get(parent)) !== null && _a !== void 0 ? _a : 0;
932
+ staggerIndex = staggerCounts.get(parent) ?? 0;
729
933
  staggerCounts.set(parent, staggerIndex + 1);
730
934
  }
731
- runAnimation(el, opts, config, staggerIndex);
935
+ runAnimation(el, opts, config, staggerIndex, pending);
732
936
  record.animated = true;
733
937
  if (opts.once && !opts.repeat) {
734
938
  // Keep the progress observer: parallax/onProgress must keep working.
@@ -744,8 +948,12 @@ function createScrollAnimate(userConfig = {}) {
744
948
  else {
745
949
  opts.onLeave(el);
746
950
  if (opts.repeat && record.animated) {
747
- if (!motionDisabled(config))
748
- hideElement(el, config);
951
+ if (!motionDisabled(config)) {
952
+ if (opts.exit)
953
+ runExit(el, opts, config);
954
+ else
955
+ hideElement(el, config);
956
+ }
749
957
  record.animated = false;
750
958
  }
751
959
  }
@@ -779,12 +987,14 @@ function createScrollAnimate(userConfig = {}) {
779
987
  }));
780
988
  }
781
989
  function attach(el, record, observeMain) {
782
- var _a;
783
990
  record.observer = getObserver(record.options);
784
991
  if (observeMain)
785
992
  record.observer.observe(el);
786
993
  record.progressObserver = needsProgress(record.options) ? getProgressObserver(record.options) : undefined;
787
- (_a = record.progressObserver) === null || _a === void 0 ? void 0 : _a.observe(el);
994
+ record.progressObserver?.observe(el);
995
+ }
996
+ function wantsNative(opts) {
997
+ return opts.engine !== 'js' && !config.useClassNames && !motionDisabled(config) && supportsScrollTimeline();
788
998
  }
789
999
  function observeElement(el, opts) {
790
1000
  if (registry.has(el) || finished.has(el))
@@ -794,12 +1004,23 @@ function createScrollAnimate(userConfig = {}) {
794
1004
  reveal(el, config);
795
1005
  return;
796
1006
  }
797
- if (!motionDisabled(config))
1007
+ const record = { element: el, options: opts, animated: false, engine: 'js' };
1008
+ if (wantsNative(opts)) {
1009
+ cancelRunning(el);
1010
+ if (startNative(el, opts, () => natives.delete(el))) {
1011
+ record.engine = 'css';
1012
+ natives.add(el);
1013
+ }
1014
+ }
1015
+ if (record.engine === 'js' && !motionDisabled(config))
798
1016
  hideElement(el, config);
799
- const record = { element: el, options: opts, animated: false };
800
1017
  registry.set(el, record);
801
1018
  attach(el, record, true);
802
1019
  }
1020
+ function observeDataElement(el) {
1021
+ if (!registry.has(el))
1022
+ observeElement(el, parseDataAttributes(el, config));
1023
+ }
803
1024
  const instance = {
804
1025
  observe(target, options = {}) {
805
1026
  pruneDetached();
@@ -813,20 +1034,71 @@ function createScrollAnimate(userConfig = {}) {
813
1034
  });
814
1035
  },
815
1036
  init(rootElement) {
816
- const scope = rootElement !== null && rootElement !== void 0 ? rootElement : (hasDOM() ? document : null);
1037
+ const scope = rootElement ?? (hasDOM() ? document : null);
817
1038
  if (!scope)
818
1039
  return;
819
1040
  pruneDetached();
820
- scope.querySelectorAll('[data-sa]').forEach((el) => {
821
- if (!registry.has(el))
822
- observeElement(el, parseDataAttributes(el, config));
1041
+ scope.querySelectorAll('[data-sa]').forEach(observeDataElement);
1042
+ },
1043
+ watch(rootElement) {
1044
+ const scope = rootElement ?? (hasDOM() ? document : null);
1045
+ if (!scope || typeof MutationObserver === 'undefined')
1046
+ return noop;
1047
+ instance.init(scope);
1048
+ const observeTree = (node) => {
1049
+ if (node.hasAttribute('data-sa'))
1050
+ observeDataElement(node);
1051
+ node.querySelectorAll('[data-sa]').forEach(observeDataElement);
1052
+ };
1053
+ const mo = new MutationObserver((records) => {
1054
+ let removed = false;
1055
+ records.forEach((record) => {
1056
+ if (record.type === 'attributes') {
1057
+ const target = record.target;
1058
+ if (target.isConnected !== false)
1059
+ observeTree(target);
1060
+ return;
1061
+ }
1062
+ record.addedNodes.forEach((node) => {
1063
+ if (node instanceof Element && node.isConnected !== false)
1064
+ observeTree(node);
1065
+ });
1066
+ if (record.removedNodes.length)
1067
+ removed = true;
1068
+ });
1069
+ // Free elements that left the DOM (they can't animate any more).
1070
+ if (removed)
1071
+ pruneDetached();
823
1072
  });
1073
+ mo.observe(scope, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-sa'] });
1074
+ watchers.add(mo);
1075
+ return () => {
1076
+ mo.disconnect();
1077
+ watchers.delete(mo);
1078
+ };
824
1079
  },
825
1080
  destroy() {
1081
+ watchers.forEach((mo) => mo.disconnect());
1082
+ watchers.clear();
1083
+ // Class-name mode: drop completion timers so onComplete never fires after destroy().
1084
+ pending.forEach((el) => {
1085
+ const timer = timers.get(el);
1086
+ if (timer !== undefined) {
1087
+ clearTimeout(timer);
1088
+ timers.delete(el);
1089
+ }
1090
+ });
1091
+ pending.clear();
826
1092
  registry.forEach((record, el) => {
827
- if (!record.animated)
1093
+ if (!record.animated && record.engine !== 'css')
1094
+ reveal(el, config);
1095
+ });
1096
+ // Native scroll-linked animations still running: stop them, show content.
1097
+ natives.forEach((el) => {
1098
+ if (running.has(el))
828
1099
  reveal(el, config);
829
1100
  });
1101
+ natives.clear();
830
1102
  pools.forEach((pool) => pool.forEach((io) => io.disconnect()));
831
1103
  pools.clear();
832
1104
  registry.clear();
@@ -851,7 +1123,7 @@ function createScrollAnimate(userConfig = {}) {
851
1123
  },
852
1124
  animate(target, options = {}) {
853
1125
  const opts = mergeOptions(options, config);
854
- resolveTargets(target).forEach((el) => runAnimation(el, opts, config, 0));
1126
+ resolveTargets(target).forEach((el) => runAnimation(el, opts, config, 0, pending));
855
1127
  },
856
1128
  getObservedElements() {
857
1129
  return Array.from(registry.values());
@@ -863,317 +1135,18 @@ function createScrollAnimate(userConfig = {}) {
863
1135
  return instance;
864
1136
  }
865
1137
 
866
- /**
867
- * use-scroll-animate - Staggered children
868
- * Reveal a container's children one after another when the container scrolls
869
- * into view, optionally also animating children that are added later.
870
- */
871
- let fallback$1 = null;
872
- /**
873
- * Animate the children of `container` with a stagger once it enters the
874
- * viewport. Returns a cleanup function. SSR-safe (no-op without a DOM).
875
- *
876
- * @example
877
- * const stop = staggerChildren(document.querySelector('ul'), { stagger: 60, observeChildren: true });
878
- */
879
- function staggerChildren(container, options = {}, instance) {
880
- if (!container || !hasDOM() || !supportsObserver())
881
- return () => undefined; // leave content visible
882
- const sa = instance || fallback$1 || (fallback$1 = createScrollAnimate());
883
- const { stagger = 80, delay = 0, threshold = 0.1, rootMargin = '0px', observeChildren = false, ...rest } = options;
884
- let items = Array.from(container.children);
885
- let revealed = false;
886
- const late = [];
887
- items.forEach((child) => prepareElement(child));
888
- const io = new IntersectionObserver((entries) => {
889
- if (revealed || !entries.some((entry) => entry.isIntersecting))
890
- return;
891
- revealed = true;
892
- io.disconnect();
893
- items.forEach((child, i) => {
894
- if (child.parentNode === container)
895
- sa.animate(child, { ...rest, delay: delay + i * stagger });
896
- });
897
- items = [];
898
- }, { threshold, rootMargin });
899
- io.observe(container);
900
- let mo;
901
- if (observeChildren && typeof MutationObserver !== 'undefined') {
902
- mo = new MutationObserver((records) => {
903
- records.forEach((record) => {
904
- record.addedNodes.forEach((node) => {
905
- if (!(node instanceof Element) || node.parentNode !== container)
906
- return;
907
- if (!revealed) {
908
- prepareElement(node);
909
- items.push(node);
910
- }
911
- else {
912
- // The core engine staggers siblings relative to the batch that
913
- // enters the viewport together.
914
- late.push(node);
915
- sa.observe(node, { ...rest, delay, stagger, threshold, rootMargin });
916
- }
917
- });
918
- record.removedNodes.forEach((node) => {
919
- if (!(node instanceof Element))
920
- return;
921
- items = items.filter((el) => el !== node);
922
- const i = late.indexOf(node);
923
- if (i >= 0) {
924
- late.splice(i, 1);
925
- sa.unobserve(node);
926
- }
927
- });
928
- });
929
- });
930
- mo.observe(container, { childList: true });
931
- }
932
- return () => {
933
- io.disconnect();
934
- mo === null || mo === void 0 ? void 0 : mo.disconnect();
935
- late.forEach((el) => sa.unobserve(el));
936
- late.length = 0;
937
- };
938
- }
939
-
940
- /**
941
- * use-scroll-animate - Sequence / timeline helper
942
- * Chain animations on several targets, one after another (or overlapping).
943
- */
944
- let fallback = null;
945
- function plan(steps, defaults) {
946
- const out = [];
947
- let cursor = 0;
948
- steps.forEach((step) => {
949
- var _a, _b;
950
- const { target, gap = 0, at, ...stepOpts } = step;
951
- const opts = { ...defaults, ...stepOpts };
952
- const duration = (_a = opts.duration) !== null && _a !== void 0 ? _a : 600;
953
- const start = Math.max(0, at !== null && at !== void 0 ? at : cursor + gap) + ((_b = opts.delay) !== null && _b !== void 0 ? _b : 0);
954
- let end = Math.max(cursor, start);
955
- resolveTargets(target).forEach((el, i) => {
956
- var _a;
957
- const delay = start + i * ((_a = opts.stagger) !== null && _a !== void 0 ? _a : 0);
958
- out.push({ el, opts: { ...opts, duration, delay, stagger: 0 }, end: delay + duration });
959
- end = Math.max(end, delay + duration);
960
- });
961
- cursor = end;
962
- });
963
- return out;
964
- }
965
- /**
966
- * Build a timeline of animations.
967
- *
968
- * @example
969
- * sequence([
970
- * { target: '.title', animation: 'fade-in-up' },
971
- * { target: '.subtitle', animation: 'blur-in', gap: -300 }, // overlap by 300ms
972
- * { target: '.card', animation: 'scale-up', stagger: 80 },
973
- * ], { trigger: '.hero' });
974
- */
975
- function sequence(steps, options = {}) {
976
- var _a, _b;
977
- const { trigger, instance, ...defaults } = options;
978
- const sa = () => instance || fallback || (fallback = createScrollAnimate());
979
- let io;
980
- let active = [];
981
- let settle;
982
- const controller = {
983
- play() {
984
- controller.cancel();
985
- if (!hasDOM())
986
- return Promise.resolve();
987
- active = plan(steps, defaults);
988
- return new Promise((resolve) => {
989
- let left = active.length;
990
- settle = () => {
991
- settle = undefined;
992
- resolve();
993
- };
994
- if (!left)
995
- return settle();
996
- const run = active;
997
- run.forEach(({ el, opts }) => {
998
- const done = opts.onComplete;
999
- sa().animate(el, {
1000
- ...opts,
1001
- onComplete: (node) => {
1002
- done === null || done === void 0 ? void 0 : done(node);
1003
- if (run === active && --left === 0)
1004
- settle === null || settle === void 0 ? void 0 : settle();
1005
- },
1006
- });
1007
- });
1008
- });
1009
- },
1010
- cancel() {
1011
- io === null || io === void 0 ? void 0 : io.disconnect();
1012
- io = undefined;
1013
- active.forEach(({ el }) => stopAnimation(el));
1014
- active = [];
1015
- settle === null || settle === void 0 ? void 0 : settle();
1016
- },
1017
- duration() {
1018
- return plan(steps, defaults).reduce((max, p) => Math.max(max, p.end), 0);
1019
- },
1020
- };
1021
- if (trigger && hasDOM() && supportsObserver()) {
1022
- const el = resolveTargets(trigger)[0];
1023
- if (el) {
1024
- plan(steps, defaults).forEach(({ el: target }) => prepareElement(target));
1025
- io = new IntersectionObserver((entries) => {
1026
- if (!entries.some((e) => e.isIntersecting))
1027
- return;
1028
- io === null || io === void 0 ? void 0 : io.disconnect();
1029
- io = undefined;
1030
- controller.play();
1031
- }, { threshold: (_a = defaults.threshold) !== null && _a !== void 0 ? _a : 0.1, rootMargin: (_b = defaults.rootMargin) !== null && _b !== void 0 ? _b : '0px' });
1032
- io.observe(el);
1033
- }
1034
- }
1035
- return controller;
1036
- }
1037
-
1038
- /**
1039
- * use-scroll-animate - React Integration
1040
- * Provides useScrollAnimate and useScrollStagger hooks for React applications.
1041
- * `useScrollStagger({ observeChildren: true })` also animates children added later.
1042
- *
1043
- * Both hooks are thin wrappers around the core engine, so they share its
1044
- * behaviour: `once`, `offset`, custom easing functions, parallax,
1045
- * `prefers-reduced-motion` support, and proper cleanup on unmount.
1046
- */
1047
- const CALLBACKS = ['onStart', 'onComplete', 'onEnter', 'onLeave', 'onProgress'];
1048
- /**
1049
- * Wrap the callbacks that exist at mount so they always call the latest
1050
- * version from the most recent render (avoids stale closures without
1051
- * re-creating observers on every render).
1052
- * @internal
1053
- */
1054
- function withLatestCallbacks(latest) {
1055
- const initial = latest.current || {};
1056
- const opts = { ...initial };
1057
- CALLBACKS.forEach((name) => {
1058
- if (typeof initial[name] === 'function') {
1059
- opts[name] = (...args) => { var _a, _b; return (_b = (_a = latest.current) === null || _a === void 0 ? void 0 : _a[name]) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); };
1060
- }
1061
- });
1062
- return opts;
1063
- }
1064
- function createReactHooks(React) {
1065
- // Created lazily on the client so importing on the server is side-effect free.
1066
- let instance = null;
1067
- const getInstance = () => instance || (instance = createScrollAnimate());
1068
- function useScrollAnimate(options = {}) {
1069
- const ref = React.useRef(null);
1070
- const optionsRef = React.useRef(options);
1071
- optionsRef.current = options;
1072
- React.useEffect(() => {
1073
- const el = ref.current;
1074
- if (!el)
1075
- return;
1076
- const sa = getInstance();
1077
- sa.observe(el, withLatestCallbacks(optionsRef));
1078
- return () => sa.unobserve(el);
1079
- }, []);
1080
- return ref;
1081
- }
1082
- function useScrollStagger(options = {}) {
1083
- const ref = React.useRef(null);
1084
- const optionsRef = React.useRef(options);
1085
- optionsRef.current = options;
1086
- React.useEffect(() => {
1087
- const container = ref.current;
1088
- if (!container)
1089
- return;
1090
- return staggerChildren(container, withLatestCallbacks(optionsRef), getInstance());
1091
- }, []);
1092
- return ref;
1093
- }
1094
- return { useScrollAnimate, useScrollStagger };
1095
- }
1096
-
1097
- /**
1098
- * use-scroll-animate - Vue 3 Integration
1099
- * Provides useScrollAnimate and useScrollStagger composables for Vue 3 applications.
1100
- *
1101
- * A thin wrapper around the core engine, so it shares its behaviour: `once`,
1102
- * `offset`, custom easing functions, parallax, `prefers-reduced-motion`
1103
- * support, and cleanup on unmount.
1104
- */
1105
- /** Support refs on components (`$el`) as well as plain elements. */
1106
- function unwrap(value) {
1107
- if (value && typeof Element !== 'undefined' && !(value instanceof Element) && value.$el instanceof Element) {
1108
- return value.$el;
1109
- }
1110
- return value || null;
1111
- }
1112
- function createVueComposables(Vue) {
1113
- // Created lazily on the client so importing on the server is side-effect free.
1114
- let instance = null;
1115
- const getInstance = () => instance || (instance = createScrollAnimate());
1116
- function useScrollAnimate(options = {}) {
1117
- const animateRef = Vue.ref(null);
1118
- let el = null;
1119
- Vue.onMounted(() => {
1120
- const target = unwrap(animateRef.value);
1121
- if (!target)
1122
- return;
1123
- el = target;
1124
- getInstance().observe(el, options);
1125
- });
1126
- Vue.onUnmounted(() => {
1127
- if (el)
1128
- getInstance().unobserve(el);
1129
- el = null;
1130
- });
1131
- return { animateRef };
1132
- }
1133
- /** Stagger the children of `staggerRef`; `observeChildren: true` also animates children added later. */
1134
- function useScrollStagger(options = {}) {
1135
- const staggerRef = Vue.ref(null);
1136
- let stop;
1137
- Vue.onMounted(() => {
1138
- const target = unwrap(staggerRef.value);
1139
- if (target)
1140
- stop = staggerChildren(target, options, getInstance());
1141
- });
1142
- Vue.onUnmounted(() => {
1143
- stop === null || stop === void 0 ? void 0 : stop();
1144
- stop = undefined;
1145
- });
1146
- return { staggerRef };
1147
- }
1148
- return { useScrollAnimate, useScrollStagger };
1149
- }
1150
-
1151
- /**
1152
- * use-scroll-animate
1153
- *
1154
- * A lightweight, dependency-free scroll animation library for modern web
1155
- * applications. Built with TypeScript, powered by IntersectionObserver and
1156
- * the Web Animations API. Safe to import during SSR.
1157
- *
1158
- * @license MIT
1159
- * @see https://github.com/HarrisonCN/use-scroll-animate
1160
- */
1161
- /**
1162
- * Default singleton instance of ScrollAnimate.
1163
- * Ready to use out of the box with sensible defaults.
1164
- *
1165
- * @example
1166
- * ```js
1167
- * import ScrollAnimate from 'use-scroll-animate';
1168
- *
1169
- * // Auto-initialize all elements with data-sa attribute
1170
- * ScrollAnimate.init();
1171
- *
1172
- * // Or manually observe elements
1173
- * ScrollAnimate.observe('.my-element', { animation: 'fade-in-up' });
1174
- * ```
1175
- */
1176
- const ScrollAnimate = createScrollAnimate();
1177
-
1178
- export { EASING_MAP, PRESETS, createReactHooks, createScrollAnimate, createVueComposables, ScrollAnimate as default, getScrollProgress, resolveEasing, resolvePreset, sequence, staggerChildren };
1179
- //# sourceMappingURL=index.mjs.map
1138
+ exports.EASING_MAP = EASING_MAP;
1139
+ exports.PRESETS = PRESETS;
1140
+ exports.createScrollAnimate = createScrollAnimate;
1141
+ exports.getScrollProgress = getScrollProgress;
1142
+ exports.hasDOM = hasDOM;
1143
+ exports.prefersReducedMotion = prefersReducedMotion;
1144
+ exports.prepareElement = prepareElement;
1145
+ exports.readOptions = readOptions;
1146
+ exports.resolveEasing = resolveEasing;
1147
+ exports.resolvePreset = resolvePreset;
1148
+ exports.resolveTargets = resolveTargets;
1149
+ exports.stopAnimation = stopAnimation;
1150
+ exports.supportsObserver = supportsObserver;
1151
+ exports.supportsScrollTimeline = supportsScrollTimeline;
1152
+ //# sourceMappingURL=core-CH41ekIo.cjs.map