use-scroll-animate 1.5.0 → 2.0.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +145 -10
  3. package/README_ja.md +57 -1
  4. package/README_zh.md +68 -1
  5. package/dist/{index.esm.js → chunks/core-BP-a1iNc.js} +297 -404
  6. package/dist/chunks/core-BP-a1iNc.js.map +1 -0
  7. package/dist/{index.mjs → chunks/core-B_J4rXbC.cjs} +312 -404
  8. package/dist/chunks/core-B_J4rXbC.cjs.map +1 -0
  9. package/dist/chunks/stagger-DHw0ExuR.cjs +86 -0
  10. package/dist/chunks/stagger-DHw0ExuR.cjs.map +1 -0
  11. package/dist/chunks/stagger-DTv_WiUQ.js +84 -0
  12. package/dist/chunks/stagger-DTv_WiUQ.js.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} +73 -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} +84 -51
  24. package/dist/index.d.ts +84 -51
  25. package/dist/index.js +98 -1133
  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
  /* ------------------------------------------------------------------ */
@@ -253,6 +271,7 @@ function lengthValue(value) {
253
271
  return /^-?(\d+\.?\d*|\.\d+)$/.test(v) ? parseFloat(v) : v;
254
272
  }
255
273
  const DEFAULT_PROGRESS_VAR = '--sa-progress';
274
+ const DEFAULT_VIEW_RANGE = ['entry 0%', 'entry 100%'];
256
275
  /** `''` (bare attribute) -> default name; `sa-progress` -> `--sa-progress`. */
257
276
  function normalizeVar(name) {
258
277
  const v = name.trim();
@@ -260,23 +279,28 @@ function normalizeVar(name) {
260
279
  return DEFAULT_PROGRESS_VAR;
261
280
  return v.startsWith('--') ? v : `--${v}`;
262
281
  }
263
- function parseDataAttributes(el, config) {
264
- var _a;
265
- const dataset = el.dataset || {};
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) {
266
289
  const opts = {};
267
- if (dataset.saAnimation) {
268
- const anim = dataset.saAnimation;
290
+ const anim = get('animation');
291
+ if (anim) {
269
292
  opts.animation = (anim.includes(',') ? anim.split(',').map((s) => s.trim()) : anim.trim());
270
293
  }
271
- opts.duration = num(dataset.saDuration);
272
- opts.delay = num(dataset.saDelay);
273
- if (dataset.saEasing) {
274
- 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();
275
299
  if (e.startsWith('[')) {
276
300
  try {
277
301
  opts.easing = JSON.parse(e);
278
302
  }
279
- catch (_b) {
303
+ catch {
280
304
  // Malformed JSON: fall back to the default easing instead of throwing
281
305
  }
282
306
  }
@@ -284,58 +308,95 @@ function parseDataAttributes(el, config) {
284
308
  opts.easing = e;
285
309
  }
286
310
  }
287
- if (dataset.saThreshold) {
288
- const t = dataset.saThreshold;
289
- opts.threshold = t.includes(',')
290
- ? t.split(',').map(parseFloat).filter(Number.isFinite)
291
- : num(t);
311
+ const t = get('threshold');
312
+ if (t) {
313
+ opts.threshold = t.includes(',') ? t.split(',').map(parseFloat).filter(Number.isFinite) : num(t);
292
314
  }
293
- if (dataset.saRootMargin)
294
- opts.rootMargin = dataset.saRootMargin;
295
- if (dataset.saRepeat !== undefined)
296
- opts.repeat = dataset.saRepeat !== 'false';
297
- if (dataset.saOnce !== undefined)
298
- opts.once = dataset.saOnce !== 'false';
299
- opts.offset = num(dataset.saOffset);
300
- opts.stagger = num(dataset.saStagger);
301
- if (dataset.saProgressVar !== undefined)
302
- opts.progressVar = normalizeVar(dataset.saProgressVar);
303
- if (dataset.saProgress)
304
- opts.progressMode = dataset.saProgress.trim() === 'scroll' ? 'scroll' : 'ratio';
305
- if (dataset.saParallaxX || dataset.saParallaxY || dataset.saParallaxRotate || dataset.saParallaxScale) {
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);
342
+ }
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) {
306
351
  opts.parallax = {
307
- x: lengthValue(dataset.saParallaxX),
308
- y: lengthValue(dataset.saParallaxY),
309
- rotate: num(dataset.saParallaxRotate),
310
- scale: num(dataset.saParallaxScale),
311
- 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,
312
357
  };
313
358
  }
314
- 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);
315
368
  }
316
369
  function mergeOptions(opts, config) {
317
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
318
- const repeat = (_a = opts.repeat) !== null && _a !== void 0 ? _a : config.defaultRepeat;
319
- 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;
320
378
  return {
321
- animation: (_c = opts.animation) !== null && _c !== void 0 ? _c : config.defaultAnimation,
322
- duration: (_d = opts.duration) !== null && _d !== void 0 ? _d : config.defaultDuration,
323
- delay: (_e = opts.delay) !== null && _e !== void 0 ? _e : config.defaultDelay,
324
- 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,
325
383
  threshold: Array.isArray(threshold) && threshold.length === 0 ? config.defaultThreshold : threshold,
326
- rootMargin: (_g = opts.rootMargin) !== null && _g !== void 0 ? _g : config.defaultRootMargin,
384
+ rootMargin: opts.rootMargin ?? config.defaultRootMargin,
327
385
  repeat,
328
- once: (_h = opts.once) !== null && _h !== void 0 ? _h : (repeat ? false : config.defaultOnce),
329
- offset: (_j = opts.offset) !== null && _j !== void 0 ? _j : config.defaultOffset,
330
- stagger: (_k = opts.stagger) !== null && _k !== void 0 ? _k : 0,
331
- parallax: (_l = opts.parallax) !== null && _l !== void 0 ? _l : {},
332
- onStart: (_m = opts.onStart) !== null && _m !== void 0 ? _m : noop,
333
- onComplete: (_o = opts.onComplete) !== null && _o !== void 0 ? _o : noop,
334
- onEnter: (_p = opts.onEnter) !== null && _p !== void 0 ? _p : noop,
335
- onLeave: (_q = opts.onLeave) !== null && _q !== void 0 ? _q : noop,
336
- onProgress: (_r = opts.onProgress) !== null && _r !== void 0 ? _r : noop,
337
- 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',
338
396
  progressVar: opts.progressVar ? normalizeVar(opts.progressVar) : '',
397
+ engine,
398
+ viewRange: opts.viewRange ?? DEFAULT_VIEW_RANGE,
399
+ exit,
339
400
  };
340
401
  }
341
402
  function resolveTargets(target) {
@@ -356,14 +417,13 @@ function resolveTargets(target) {
356
417
  * @internal
357
418
  */
358
419
  function applyOffset(rootMargin, offset) {
359
- var _a, _b, _c;
360
420
  if (!offset)
361
421
  return rootMargin;
362
422
  const parts = (rootMargin || '0px').trim().split(/\s+/);
363
423
  const top = parts[0];
364
- const right = (_a = parts[1]) !== null && _a !== void 0 ? _a : top;
365
- const bottom = (_b = parts[2]) !== null && _b !== void 0 ? _b : top;
366
- 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;
367
427
  const m = /^(-?\d*\.?\d+)(px)?$/.exec(bottom);
368
428
  const base = m ? parseFloat(m[1]) : 0; // non-px bottoms (e.g. %) cannot be combined; offset wins
369
429
  return `${top} ${right} ${base - offset}px ${left}`;
@@ -401,8 +461,15 @@ function getScrollProgress(el, root) {
401
461
  /* ------------------------------------------------------------------ */
402
462
  /** The animation currently running on an element, so it can be cancelled/replaced. */
403
463
  const running = new WeakMap();
464
+ /** Native engine: the scroll-linked exit animation, next to the entrance in `running`. */
465
+ const exits = new WeakMap();
404
466
  const timers = new WeakMap();
405
467
  function cancelRunning(el) {
468
+ const exitAnim = exits.get(el);
469
+ if (exitAnim) {
470
+ exits.delete(el);
471
+ exitAnim.cancel();
472
+ }
406
473
  const anim = running.get(el);
407
474
  if (anim) {
408
475
  running.delete(el);
@@ -513,7 +580,7 @@ function stopAnimation(el, config = {}) {
513
580
  function motionDisabled(config) {
514
581
  return !!config.disabled || prefersReducedMotion();
515
582
  }
516
- function runAnimation(el, opts, config, staggerIndex = 0) {
583
+ function runAnimation(el, opts, config, staggerIndex = 0, pending) {
517
584
  const { duration, delay, stagger, onStart, onComplete } = opts;
518
585
  const totalDelay = Math.max(0, delay + staggerIndex * stagger);
519
586
  cancelRunning(el);
@@ -529,8 +596,10 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
529
596
  el.classList.remove(config.hiddenClass);
530
597
  el.classList.add(config.visibleClass);
531
598
  onStart(el);
599
+ pending?.add(el);
532
600
  timers.set(el, setTimeout(() => {
533
601
  timers.delete(el);
602
+ pending?.delete(el);
534
603
  onComplete(el);
535
604
  }, duration + totalDelay));
536
605
  return;
@@ -555,7 +624,7 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
555
624
  try {
556
625
  anim = el.animate(built.keyframes, timing);
557
626
  }
558
- catch (_a) {
627
+ catch {
559
628
  // Invalid user easing string (WAAPI throws a TypeError): fall back to 'ease'
560
629
  anim = el.animate(built.keyframes, { ...timing, easing: 'ease' });
561
630
  }
@@ -570,7 +639,7 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
570
639
  try {
571
640
  anim.commitStyles();
572
641
  }
573
- catch (_a) {
642
+ catch {
574
643
  setStyles(el, preset.to);
575
644
  }
576
645
  anim.cancel();
@@ -578,6 +647,69 @@ function runAnimation(el, opts, config, staggerIndex = 0) {
578
647
  onComplete(el);
579
648
  };
580
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
+ }
581
713
  function applyParallax(el, progress, parallax) {
582
714
  const { x = 0, y = 0, rotate = 0, scale = 1, speed = 1 } = parallax;
583
715
  const p = (progress - 0.5) * 2 * speed;
@@ -593,6 +725,26 @@ function applyParallax(el, progress, parallax) {
593
725
  transform += ` scale(${1 + (scale - 1) * p})`;
594
726
  el.style.transform = transform.trim();
595
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
+ }
596
748
  function hideElement(el, config) {
597
749
  cancelRunning(el);
598
750
  if (config.useClassNames) {
@@ -635,6 +787,10 @@ function createScrollAnimate(userConfig = {}) {
635
787
  let listening = null;
636
788
  // Active watch() MutationObservers, disconnected by destroy().
637
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();
638
794
  // Observers are shared between elements with the same root/threshold/rootMargin,
639
795
  // instead of one (or two) IntersectionObservers per element.
640
796
  const pools = new Map();
@@ -648,14 +804,27 @@ function createScrollAnimate(userConfig = {}) {
648
804
  return io;
649
805
  }
650
806
  function teardown(el, restore) {
651
- var _a;
652
807
  const record = registry.get(el);
653
808
  if (!record)
654
809
  return;
655
810
  record.observer.unobserve(el);
656
- (_a = record.progressObserver) === null || _a === void 0 ? void 0 : _a.unobserve(el);
811
+ record.progressObserver?.unobserve(el);
657
812
  registry.delete(el);
658
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
+ else if (el.isConnected === false) {
822
+ // Left the DOM: drop it too, or the instance keeps it alive (and
823
+ // destroy() touches it) for its whole lifetime.
824
+ natives.delete(el);
825
+ }
826
+ return;
827
+ }
659
828
  // An element that never animated would otherwise stay invisible forever.
660
829
  if (restore && !record.animated)
661
830
  reveal(el, config);
@@ -724,7 +893,6 @@ function createScrollAnimate(userConfig = {}) {
724
893
  function onIntersect(entries) {
725
894
  const staggerCounts = new Map();
726
895
  entries.forEach((entry) => {
727
- var _a;
728
896
  const el = entry.target;
729
897
  const record = registry.get(el);
730
898
  if (!record)
@@ -734,6 +902,29 @@ function createScrollAnimate(userConfig = {}) {
734
902
  teardown(el, false);
735
903
  return;
736
904
  }
905
+ if (record.engine === 'css') {
906
+ // The browser drives the animation; only lifecycle bookkeeping here.
907
+ if (entry.isIntersecting) {
908
+ opts.onEnter(el);
909
+ if (!record.animated) {
910
+ record.animated = true;
911
+ opts.onStart(el);
912
+ }
913
+ if (opts.once && !opts.repeat) {
914
+ record.observer.unobserve(el);
915
+ if (config.autoUnregister && !record.progressObserver) {
916
+ finished.add(el);
917
+ teardown(el, false);
918
+ }
919
+ }
920
+ }
921
+ else {
922
+ opts.onLeave(el);
923
+ if (opts.repeat)
924
+ record.animated = false;
925
+ }
926
+ return;
927
+ }
737
928
  if (entry.isIntersecting) {
738
929
  opts.onEnter(el);
739
930
  if (record.animated && !opts.repeat)
@@ -743,10 +934,10 @@ function createScrollAnimate(userConfig = {}) {
743
934
  let staggerIndex = 0;
744
935
  if (opts.stagger > 0) {
745
936
  const parent = el.parentElement;
746
- staggerIndex = (_a = staggerCounts.get(parent)) !== null && _a !== void 0 ? _a : 0;
937
+ staggerIndex = staggerCounts.get(parent) ?? 0;
747
938
  staggerCounts.set(parent, staggerIndex + 1);
748
939
  }
749
- runAnimation(el, opts, config, staggerIndex);
940
+ runAnimation(el, opts, config, staggerIndex, pending);
750
941
  record.animated = true;
751
942
  if (opts.once && !opts.repeat) {
752
943
  // Keep the progress observer: parallax/onProgress must keep working.
@@ -762,8 +953,12 @@ function createScrollAnimate(userConfig = {}) {
762
953
  else {
763
954
  opts.onLeave(el);
764
955
  if (opts.repeat && record.animated) {
765
- if (!motionDisabled(config))
766
- hideElement(el, config);
956
+ if (!motionDisabled(config)) {
957
+ if (opts.exit)
958
+ runExit(el, opts, config);
959
+ else
960
+ hideElement(el, config);
961
+ }
767
962
  record.animated = false;
768
963
  }
769
964
  }
@@ -797,12 +992,14 @@ function createScrollAnimate(userConfig = {}) {
797
992
  }));
798
993
  }
799
994
  function attach(el, record, observeMain) {
800
- var _a;
801
995
  record.observer = getObserver(record.options);
802
996
  if (observeMain)
803
997
  record.observer.observe(el);
804
998
  record.progressObserver = needsProgress(record.options) ? getProgressObserver(record.options) : undefined;
805
- (_a = record.progressObserver) === null || _a === void 0 ? void 0 : _a.observe(el);
999
+ record.progressObserver?.observe(el);
1000
+ }
1001
+ function wantsNative(opts) {
1002
+ return opts.engine !== 'js' && !config.useClassNames && !motionDisabled(config) && supportsScrollTimeline();
806
1003
  }
807
1004
  function observeElement(el, opts) {
808
1005
  if (registry.has(el) || finished.has(el))
@@ -812,9 +1009,16 @@ function createScrollAnimate(userConfig = {}) {
812
1009
  reveal(el, config);
813
1010
  return;
814
1011
  }
815
- if (!motionDisabled(config))
1012
+ const record = { element: el, options: opts, animated: false, engine: 'js' };
1013
+ if (wantsNative(opts)) {
1014
+ cancelRunning(el);
1015
+ if (startNative(el, opts, () => natives.delete(el))) {
1016
+ record.engine = 'css';
1017
+ natives.add(el);
1018
+ }
1019
+ }
1020
+ if (record.engine === 'js' && !motionDisabled(config))
816
1021
  hideElement(el, config);
817
- const record = { element: el, options: opts, animated: false };
818
1022
  registry.set(el, record);
819
1023
  attach(el, record, true);
820
1024
  }
@@ -835,14 +1039,14 @@ function createScrollAnimate(userConfig = {}) {
835
1039
  });
836
1040
  },
837
1041
  init(rootElement) {
838
- const scope = rootElement !== null && rootElement !== void 0 ? rootElement : (hasDOM() ? document : null);
1042
+ const scope = rootElement ?? (hasDOM() ? document : null);
839
1043
  if (!scope)
840
1044
  return;
841
1045
  pruneDetached();
842
1046
  scope.querySelectorAll('[data-sa]').forEach(observeDataElement);
843
1047
  },
844
1048
  watch(rootElement) {
845
- const scope = rootElement !== null && rootElement !== void 0 ? rootElement : (hasDOM() ? document : null);
1049
+ const scope = rootElement ?? (hasDOM() ? document : null);
846
1050
  if (!scope || typeof MutationObserver === 'undefined')
847
1051
  return noop;
848
1052
  instance.init(scope);
@@ -881,10 +1085,25 @@ function createScrollAnimate(userConfig = {}) {
881
1085
  destroy() {
882
1086
  watchers.forEach((mo) => mo.disconnect());
883
1087
  watchers.clear();
1088
+ // Class-name mode: drop completion timers so onComplete never fires after destroy().
1089
+ pending.forEach((el) => {
1090
+ const timer = timers.get(el);
1091
+ if (timer !== undefined) {
1092
+ clearTimeout(timer);
1093
+ timers.delete(el);
1094
+ }
1095
+ });
1096
+ pending.clear();
884
1097
  registry.forEach((record, el) => {
885
- if (!record.animated)
1098
+ if (!record.animated && record.engine !== 'css')
1099
+ reveal(el, config);
1100
+ });
1101
+ // Native scroll-linked animations still running: stop them, show content.
1102
+ natives.forEach((el) => {
1103
+ if (running.has(el))
886
1104
  reveal(el, config);
887
1105
  });
1106
+ natives.clear();
888
1107
  pools.forEach((pool) => pool.forEach((io) => io.disconnect()));
889
1108
  pools.clear();
890
1109
  registry.clear();
@@ -909,7 +1128,7 @@ function createScrollAnimate(userConfig = {}) {
909
1128
  },
910
1129
  animate(target, options = {}) {
911
1130
  const opts = mergeOptions(options, config);
912
- resolveTargets(target).forEach((el) => runAnimation(el, opts, config, 0));
1131
+ resolveTargets(target).forEach((el) => runAnimation(el, opts, config, 0, pending));
913
1132
  },
914
1133
  getObservedElements() {
915
1134
  return Array.from(registry.values());
@@ -921,329 +1140,18 @@ function createScrollAnimate(userConfig = {}) {
921
1140
  return instance;
922
1141
  }
923
1142
 
924
- /**
925
- * use-scroll-animate - Staggered children
926
- * Reveal a container's children one after another when the container scrolls
927
- * into view, optionally also animating children that are added later.
928
- */
929
- let fallback$1 = null;
930
- /**
931
- * Animate the children of `container` with a stagger once it enters the
932
- * viewport. Returns a cleanup function. SSR-safe (no-op without a DOM).
933
- *
934
- * @example
935
- * const stop = staggerChildren(document.querySelector('ul'), { stagger: 60, observeChildren: true });
936
- */
937
- function staggerChildren(container, options = {}, instance) {
938
- if (!container || !hasDOM() || !supportsObserver())
939
- return () => undefined; // leave content visible
940
- const sa = instance || fallback$1 || (fallback$1 = createScrollAnimate());
941
- const { stagger = 80, delay = 0, threshold = 0.1, rootMargin = '0px', observeChildren = false, ...rest } = options;
942
- let items = Array.from(container.children);
943
- let revealed = false;
944
- const late = [];
945
- items.forEach((child) => prepareElement(child));
946
- const io = new IntersectionObserver((entries) => {
947
- if (revealed || !entries.some((entry) => entry.isIntersecting))
948
- return;
949
- revealed = true;
950
- io.disconnect();
951
- items.forEach((child, i) => {
952
- if (child.parentNode === container)
953
- sa.animate(child, { ...rest, delay: delay + i * stagger });
954
- });
955
- items = [];
956
- }, { threshold, rootMargin });
957
- io.observe(container);
958
- let mo;
959
- if (observeChildren && typeof MutationObserver !== 'undefined') {
960
- mo = new MutationObserver((records) => {
961
- records.forEach((record) => {
962
- record.addedNodes.forEach((node) => {
963
- if (!(node instanceof Element) || node.parentNode !== container)
964
- return;
965
- if (!revealed) {
966
- prepareElement(node);
967
- items.push(node);
968
- }
969
- else {
970
- // The core engine staggers siblings relative to the batch that
971
- // enters the viewport together.
972
- late.push(node);
973
- sa.observe(node, { ...rest, delay, stagger, threshold, rootMargin });
974
- }
975
- });
976
- record.removedNodes.forEach((node) => {
977
- if (!(node instanceof Element))
978
- return;
979
- items = items.filter((el) => el !== node);
980
- const i = late.indexOf(node);
981
- if (i >= 0) {
982
- late.splice(i, 1);
983
- sa.unobserve(node);
984
- }
985
- });
986
- });
987
- });
988
- mo.observe(container, { childList: true });
989
- }
990
- return () => {
991
- io.disconnect();
992
- mo === null || mo === void 0 ? void 0 : mo.disconnect();
993
- // Stopped before the container was revealed: never leave the children hidden.
994
- if (!revealed) {
995
- revealed = true;
996
- items.forEach((child) => stopAnimation(child));
997
- items = [];
998
- }
999
- late.forEach((el) => sa.unobserve(el));
1000
- late.length = 0;
1001
- };
1002
- }
1003
-
1004
- /**
1005
- * use-scroll-animate - Sequence / timeline helper
1006
- * Chain animations on several targets, one after another (or overlapping).
1007
- */
1008
- let fallback = null;
1009
- function plan(steps, defaults) {
1010
- const out = [];
1011
- let cursor = 0;
1012
- steps.forEach((step) => {
1013
- var _a, _b;
1014
- const { target, gap = 0, at, ...stepOpts } = step;
1015
- const opts = { ...defaults, ...stepOpts };
1016
- const duration = (_a = opts.duration) !== null && _a !== void 0 ? _a : 600;
1017
- const start = Math.max(0, at !== null && at !== void 0 ? at : cursor + gap) + ((_b = opts.delay) !== null && _b !== void 0 ? _b : 0);
1018
- let end = Math.max(cursor, start);
1019
- resolveTargets(target).forEach((el, i) => {
1020
- var _a;
1021
- const delay = start + i * ((_a = opts.stagger) !== null && _a !== void 0 ? _a : 0);
1022
- out.push({ el, opts: { ...opts, duration, delay, stagger: 0 }, end: delay + duration });
1023
- end = Math.max(end, delay + duration);
1024
- });
1025
- cursor = end;
1026
- });
1027
- return out;
1028
- }
1029
- /**
1030
- * Build a timeline of animations.
1031
- *
1032
- * @example
1033
- * sequence([
1034
- * { target: '.title', animation: 'fade-in-up' },
1035
- * { target: '.subtitle', animation: 'blur-in', gap: -300 }, // overlap by 300ms
1036
- * { target: '.card', animation: 'scale-up', stagger: 80 },
1037
- * ], { trigger: '.hero' });
1038
- */
1039
- function sequence(steps, options = {}) {
1040
- var _a, _b;
1041
- const { trigger, instance, ...defaults } = options;
1042
- const sa = () => instance || fallback || (fallback = createScrollAnimate());
1043
- let io;
1044
- let active = [];
1045
- let settle;
1046
- // Targets hidden while waiting for `trigger`; revealed if cancelled before it fires.
1047
- let prepared = [];
1048
- const controller = {
1049
- play() {
1050
- controller.cancel();
1051
- if (!hasDOM())
1052
- return Promise.resolve();
1053
- active = plan(steps, defaults);
1054
- return new Promise((resolve) => {
1055
- let left = active.length;
1056
- settle = () => {
1057
- settle = undefined;
1058
- resolve();
1059
- };
1060
- if (!left)
1061
- return settle();
1062
- const run = active;
1063
- run.forEach(({ el, opts }) => {
1064
- const done = opts.onComplete;
1065
- sa().animate(el, {
1066
- ...opts,
1067
- onComplete: (node) => {
1068
- done === null || done === void 0 ? void 0 : done(node);
1069
- if (run === active && --left === 0)
1070
- settle === null || settle === void 0 ? void 0 : settle();
1071
- },
1072
- });
1073
- });
1074
- });
1075
- },
1076
- cancel() {
1077
- io === null || io === void 0 ? void 0 : io.disconnect();
1078
- io = undefined;
1079
- prepared.forEach((el) => stopAnimation(el));
1080
- prepared = [];
1081
- active.forEach(({ el }) => stopAnimation(el));
1082
- active = [];
1083
- settle === null || settle === void 0 ? void 0 : settle();
1084
- },
1085
- duration() {
1086
- return plan(steps, defaults).reduce((max, p) => Math.max(max, p.end), 0);
1087
- },
1088
- };
1089
- if (trigger && hasDOM() && supportsObserver()) {
1090
- const el = resolveTargets(trigger)[0];
1091
- if (el) {
1092
- prepared = plan(steps, defaults).map(({ el: target }) => target);
1093
- prepared.forEach((target) => prepareElement(target));
1094
- io = new IntersectionObserver((entries) => {
1095
- if (!entries.some((e) => e.isIntersecting))
1096
- return;
1097
- io === null || io === void 0 ? void 0 : io.disconnect();
1098
- io = undefined;
1099
- prepared = []; // play() takes over from here
1100
- controller.play();
1101
- }, { threshold: (_a = defaults.threshold) !== null && _a !== void 0 ? _a : 0.1, rootMargin: (_b = defaults.rootMargin) !== null && _b !== void 0 ? _b : '0px' });
1102
- io.observe(el);
1103
- }
1104
- }
1105
- return controller;
1106
- }
1107
-
1108
- /**
1109
- * use-scroll-animate - React Integration
1110
- * Provides useScrollAnimate and useScrollStagger hooks for React applications.
1111
- * `useScrollStagger({ observeChildren: true })` also animates children added later.
1112
- *
1113
- * Both hooks are thin wrappers around the core engine, so they share its
1114
- * behaviour: `once`, `offset`, custom easing functions, parallax,
1115
- * `prefers-reduced-motion` support, and proper cleanup on unmount.
1116
- */
1117
- const CALLBACKS = ['onStart', 'onComplete', 'onEnter', 'onLeave', 'onProgress'];
1118
- /**
1119
- * Wrap the callbacks that exist at mount so they always call the latest
1120
- * version from the most recent render (avoids stale closures without
1121
- * re-creating observers on every render).
1122
- * @internal
1123
- */
1124
- function withLatestCallbacks(latest) {
1125
- const initial = latest.current || {};
1126
- const opts = { ...initial };
1127
- CALLBACKS.forEach((name) => {
1128
- if (typeof initial[name] === 'function') {
1129
- 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); };
1130
- }
1131
- });
1132
- return opts;
1133
- }
1134
- function createReactHooks(React) {
1135
- // Created lazily on the client so importing on the server is side-effect free.
1136
- let instance = null;
1137
- const getInstance = () => instance || (instance = createScrollAnimate());
1138
- function useScrollAnimate(options = {}) {
1139
- const ref = React.useRef(null);
1140
- const optionsRef = React.useRef(options);
1141
- optionsRef.current = options;
1142
- React.useEffect(() => {
1143
- const el = ref.current;
1144
- if (!el)
1145
- return;
1146
- const sa = getInstance();
1147
- sa.observe(el, withLatestCallbacks(optionsRef));
1148
- return () => sa.unobserve(el);
1149
- }, []);
1150
- return ref;
1151
- }
1152
- function useScrollStagger(options = {}) {
1153
- const ref = React.useRef(null);
1154
- const optionsRef = React.useRef(options);
1155
- optionsRef.current = options;
1156
- React.useEffect(() => {
1157
- const container = ref.current;
1158
- if (!container)
1159
- return;
1160
- return staggerChildren(container, withLatestCallbacks(optionsRef), getInstance());
1161
- }, []);
1162
- return ref;
1163
- }
1164
- return { useScrollAnimate, useScrollStagger };
1165
- }
1166
-
1167
- /**
1168
- * use-scroll-animate - Vue 3 Integration
1169
- * Provides useScrollAnimate and useScrollStagger composables for Vue 3 applications.
1170
- *
1171
- * A thin wrapper around the core engine, so it shares its behaviour: `once`,
1172
- * `offset`, custom easing functions, parallax, `prefers-reduced-motion`
1173
- * support, and cleanup on unmount.
1174
- */
1175
- /** Support refs on components (`$el`) as well as plain elements. */
1176
- function unwrap(value) {
1177
- if (value && typeof Element !== 'undefined' && !(value instanceof Element) && value.$el instanceof Element) {
1178
- return value.$el;
1179
- }
1180
- return value || null;
1181
- }
1182
- function createVueComposables(Vue) {
1183
- // Created lazily on the client so importing on the server is side-effect free.
1184
- let instance = null;
1185
- const getInstance = () => instance || (instance = createScrollAnimate());
1186
- function useScrollAnimate(options = {}) {
1187
- const animateRef = Vue.ref(null);
1188
- let el = null;
1189
- Vue.onMounted(() => {
1190
- const target = unwrap(animateRef.value);
1191
- if (!target)
1192
- return;
1193
- el = target;
1194
- getInstance().observe(el, options);
1195
- });
1196
- Vue.onUnmounted(() => {
1197
- if (el)
1198
- getInstance().unobserve(el);
1199
- el = null;
1200
- });
1201
- return { animateRef };
1202
- }
1203
- /** Stagger the children of `staggerRef`; `observeChildren: true` also animates children added later. */
1204
- function useScrollStagger(options = {}) {
1205
- const staggerRef = Vue.ref(null);
1206
- let stop;
1207
- Vue.onMounted(() => {
1208
- const target = unwrap(staggerRef.value);
1209
- if (target)
1210
- stop = staggerChildren(target, options, getInstance());
1211
- });
1212
- Vue.onUnmounted(() => {
1213
- stop === null || stop === void 0 ? void 0 : stop();
1214
- stop = undefined;
1215
- });
1216
- return { staggerRef };
1217
- }
1218
- return { useScrollAnimate, useScrollStagger };
1219
- }
1220
-
1221
- /**
1222
- * use-scroll-animate
1223
- *
1224
- * A lightweight, dependency-free scroll animation library for modern web
1225
- * applications. Built with TypeScript, powered by IntersectionObserver and
1226
- * the Web Animations API. Safe to import during SSR.
1227
- *
1228
- * @license MIT
1229
- * @see https://github.com/HarrisonCN/use-scroll-animate
1230
- */
1231
- /**
1232
- * Default singleton instance of ScrollAnimate.
1233
- * Ready to use out of the box with sensible defaults.
1234
- *
1235
- * @example
1236
- * ```js
1237
- * import ScrollAnimate from 'use-scroll-animate';
1238
- *
1239
- * // Auto-initialize all elements with data-sa attribute
1240
- * ScrollAnimate.init();
1241
- *
1242
- * // Or manually observe elements
1243
- * ScrollAnimate.observe('.my-element', { animation: 'fade-in-up' });
1244
- * ```
1245
- */
1246
- const ScrollAnimate = createScrollAnimate();
1247
-
1248
- export { EASING_MAP, PRESETS, createReactHooks, createScrollAnimate, createVueComposables, ScrollAnimate as default, getScrollProgress, resolveEasing, resolvePreset, sequence, staggerChildren };
1249
- //# sourceMappingURL=index.mjs.map
1143
+ exports.EASING_MAP = EASING_MAP;
1144
+ exports.PRESETS = PRESETS;
1145
+ exports.createScrollAnimate = createScrollAnimate;
1146
+ exports.getScrollProgress = getScrollProgress;
1147
+ exports.hasDOM = hasDOM;
1148
+ exports.prefersReducedMotion = prefersReducedMotion;
1149
+ exports.prepareElement = prepareElement;
1150
+ exports.readOptions = readOptions;
1151
+ exports.resolveEasing = resolveEasing;
1152
+ exports.resolvePreset = resolvePreset;
1153
+ exports.resolveTargets = resolveTargets;
1154
+ exports.stopAnimation = stopAnimation;
1155
+ exports.supportsObserver = supportsObserver;
1156
+ exports.supportsScrollTimeline = supportsScrollTimeline;
1157
+ //# sourceMappingURL=core-B_J4rXbC.cjs.map