use-scroll-animate 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-10-07
11
+
12
+ ### Added
13
+ - `watch(root?)` instance method: automatically observes `[data-sa]` elements added to the DOM later; returns a stop function, and `destroy()` stops all watchers.
14
+ - `progressVar` option and `data-sa-progress-var` attribute: expose scroll progress (0–1) as a CSS custom property.
15
+
16
+ ### Fixed
17
+ - Stopping `staggerChildren` or cancelling a triggered `sequence()` before the content entered the viewport left it at `opacity: 0`; it is now restored (also affects React/Vue `useScrollStagger` unmounting off-screen).
18
+
19
+ ### Tests / CI
20
+ - 47 new tests covering reduced motion, SSR, unmount cleanup and lifecycle; CI job timeout and `npm pack --dry-run`.
21
+
10
22
  ## [1.4.0] - 2026-10-06
11
23
 
12
24
  ### Added
package/README.md CHANGED
@@ -144,19 +144,42 @@ We've added high-quality physics-based easing presets:
144
144
  | `onStart` / `onComplete` / `onEnter` / `onLeave` | `(el) => void` | – | Lifecycle callbacks |
145
145
  | `onProgress` | `(el, progress) => void` | – | Progress (0–1) as the element scrolls — visible ratio, or true scroll progress with `progressMode: 'scroll'` |
146
146
  | `progressMode` | `'ratio'` \| `'scroll'` | `'ratio'` | How `onProgress`/parallax progress is measured (`'scroll'`: 0 = top enters at the bottom, 1 = bottom leaves at the top) |
147
+ | `progressVar` | `string` | – | Write progress (0–1, same value as `onProgress`) to this CSS custom property, e.g. `'--sa-progress'`, for scroll-driven effects in plain CSS |
147
148
 
148
- Every option is also available as a data attribute: `data-sa-animation`, `data-sa-duration`, `data-sa-delay`, `data-sa-easing`, `data-sa-threshold`, `data-sa-root-margin`, `data-sa-once`, `data-sa-repeat`, `data-sa-offset`, `data-sa-stagger`, `data-sa-progress`, `data-sa-parallax-x|y|rotate|scale|speed`.
149
+ Every option is also available as a data attribute: `data-sa-animation`, `data-sa-duration`, `data-sa-delay`, `data-sa-easing`, `data-sa-threshold`, `data-sa-root-margin`, `data-sa-once`, `data-sa-repeat`, `data-sa-offset`, `data-sa-stagger`, `data-sa-progress`, `data-sa-progress-var` (bare attribute = `--sa-progress`), `data-sa-parallax-x|y|rotate|scale|speed`.
149
150
 
150
151
  **Presets:** `fade-in`, `fade-in-up|down|left|right`, `zoom-in`, `zoom-out`, `scale-up`, `flip-x`, `flip-y`, `flip-up`, `flip-down`, `slide-up|down|left|right`, `bounce`, `rotate-in`, `rotate-left`, `rotate-right`, `blur-in`, `blur-in-up`, `skew-in`, `scale-x`, `scale-y`, `clip-up|down|left|right`, `clip-circle`, `shimmer`, `pulse`, `swing`. Combine them with an array, e.g. `['fade-in', 'clip-up']`.
151
152
 
152
153
  **Global config** (`createScrollAnimate(config)` / `configure()`): `defaultAnimation`, `defaultDuration`, `defaultDelay`, `defaultEasing`, `defaultThreshold`, `defaultRootMargin`, `defaultRepeat`, `defaultOnce`, `defaultOffset`, `hiddenClass`, `visibleClass`, `useClassNames`, `disabled`, `root`, `autoUnregister` (default `true`).
153
154
 
155
+ ### Progress as a CSS variable (`progressVar`)
156
+
157
+ Drive any CSS property from scroll position without writing JavaScript callbacks. The element's progress is written to a custom property on the element itself:
158
+
159
+ ```html
160
+ <div data-sa data-sa-progress="scroll" data-sa-progress-var class="hero">…</div>
161
+
162
+ <style>
163
+ @media (prefers-reduced-motion: no-preference) {
164
+ .hero { transform: translateY(calc((1 - var(--sa-progress, 0)) * 60px)); opacity: calc(0.4 + var(--sa-progress, 0)); }
165
+ }
166
+ </style>
167
+ ```
168
+
169
+ ```js
170
+ ScrollAnimate.observe('.bar', { progressVar: '--fill', progressMode: 'scroll' });
171
+ // .bar::after { transform: scaleX(var(--fill, 0)); }
172
+ ```
173
+
174
+ It uses the same rAF-throttled / IntersectionObserver pipeline as `onProgress`, keeps updating after the entrance animation, and is still written under reduced motion (it is data) — guard motion in your CSS with `prefers-reduced-motion` as above.
175
+
154
176
  ## Instance API
155
177
 
156
178
  ```js
157
179
  import ScrollAnimate, { createScrollAnimate } from 'use-scroll-animate';
158
180
 
159
181
  ScrollAnimate.init(root?); // observe every [data-sa] element (safe to call again after DOM changes)
182
+ const stop = ScrollAnimate.watch(root?); // init() + auto-observe [data-sa] elements added later; stop() to end
160
183
  ScrollAnimate.observe(target, opts); // selector, Element, NodeList or Element[]
161
184
  ScrollAnimate.unobserve(target); // stop observing (elements that never animated are made visible)
162
185
  ScrollAnimate.animate(target, opts); // play an animation right now
@@ -170,6 +193,22 @@ const sa = createScrollAnimate({ root: document.querySelector('#scroller') }); /
170
193
  import { sequence, staggerChildren, getScrollProgress } from 'use-scroll-animate';
171
194
  ```
172
195
 
196
+ ### Watching the DOM (`watch()`)
197
+
198
+ For SPAs, CMS content, infinite lists or anything rendered after page load, `watch()` replaces "call `init()` again after every DOM change":
199
+
200
+ ```js
201
+ import ScrollAnimate from 'use-scroll-animate';
202
+
203
+ const stop = ScrollAnimate.watch(); // or watch(document.querySelector('#app'))
204
+ // [data-sa] elements inserted later — even deep inside a new subtree, or an existing
205
+ // element that gains the data-sa attribute — are observed with their data-sa-* options.
206
+ // Elements removed from the DOM are released; finished `once` elements are never replayed.
207
+ stop(); // stop watching (destroy() also stops every watcher)
208
+ ```
209
+
210
+ It uses a single `MutationObserver` per call and is a no-op on the server or without `MutationObserver`.
211
+
173
212
  Via a `<script>` tag (UMD build), the default instance lives at `ScrollAnimate.default`:
174
213
 
175
214
  ```html
package/dist/index.d.mts CHANGED
@@ -71,6 +71,12 @@ interface AnimateOptions {
71
71
  onProgress?: (element: Element, progress: number) => void;
72
72
  /** How progress for `onProgress`/parallax is measured (default: 'ratio') */
73
73
  progressMode?: ProgressMode;
74
+ /**
75
+ * Name of a CSS custom property (e.g. `'--sa-progress'`) that receives the
76
+ * element's progress (0 to 1, same value as `onProgress`) as an inline
77
+ * style, for scroll-driven effects written in plain CSS. Off by default.
78
+ */
79
+ progressVar?: string;
74
80
  }
75
81
  /** Global configuration for ScrollAnimate instance */
76
82
  interface ScrollAnimateConfig {
@@ -126,6 +132,14 @@ interface ScrollAnimateInstance {
126
132
  unobserve(target: string | Element | NodeList | Element[]): void;
127
133
  /** Observe all elements matching the data-sa attribute */
128
134
  init(rootElement?: Element | Document): void;
135
+ /**
136
+ * Like `init()`, then keep watching `rootElement` (default: `document`) with a
137
+ * MutationObserver: `[data-sa]` elements added later (or that gain the
138
+ * attribute) are observed automatically, and removed ones are released.
139
+ * Returns a function that stops watching. `destroy()` stops every watcher.
140
+ * SSR-safe: a no-op without a DOM / MutationObserver.
141
+ */
142
+ watch(rootElement?: Element | Document): () => void;
129
143
  /** Destroy the instance and clean up all observers */
130
144
  destroy(): void;
131
145
  /** Refresh all observers (useful after DOM changes) */
package/dist/index.d.ts CHANGED
@@ -71,6 +71,12 @@ interface AnimateOptions {
71
71
  onProgress?: (element: Element, progress: number) => void;
72
72
  /** How progress for `onProgress`/parallax is measured (default: 'ratio') */
73
73
  progressMode?: ProgressMode;
74
+ /**
75
+ * Name of a CSS custom property (e.g. `'--sa-progress'`) that receives the
76
+ * element's progress (0 to 1, same value as `onProgress`) as an inline
77
+ * style, for scroll-driven effects written in plain CSS. Off by default.
78
+ */
79
+ progressVar?: string;
74
80
  }
75
81
  /** Global configuration for ScrollAnimate instance */
76
82
  interface ScrollAnimateConfig {
@@ -126,6 +132,14 @@ interface ScrollAnimateInstance {
126
132
  unobserve(target: string | Element | NodeList | Element[]): void;
127
133
  /** Observe all elements matching the data-sa attribute */
128
134
  init(rootElement?: Element | Document): void;
135
+ /**
136
+ * Like `init()`, then keep watching `rootElement` (default: `document`) with a
137
+ * MutationObserver: `[data-sa]` elements added later (or that gain the
138
+ * attribute) are observed automatically, and removed ones are released.
139
+ * Returns a function that stops watching. `destroy()` stops every watcher.
140
+ * SSR-safe: a no-op without a DOM / MutationObserver.
141
+ */
142
+ watch(rootElement?: Element | Document): () => void;
129
143
  /** Destroy the instance and clean up all observers */
130
144
  destroy(): void;
131
145
  /** Refresh all observers (useful after DOM changes) */
package/dist/index.esm.js CHANGED
@@ -252,6 +252,14 @@ function lengthValue(value) {
252
252
  const v = value.trim();
253
253
  return /^-?(\d+\.?\d*|\.\d+)$/.test(v) ? parseFloat(v) : v;
254
254
  }
255
+ const DEFAULT_PROGRESS_VAR = '--sa-progress';
256
+ /** `''` (bare attribute) -> default name; `sa-progress` -> `--sa-progress`. */
257
+ function normalizeVar(name) {
258
+ const v = name.trim();
259
+ if (!v)
260
+ return DEFAULT_PROGRESS_VAR;
261
+ return v.startsWith('--') ? v : `--${v}`;
262
+ }
255
263
  function parseDataAttributes(el, config) {
256
264
  var _a;
257
265
  const dataset = el.dataset || {};
@@ -290,6 +298,8 @@ function parseDataAttributes(el, config) {
290
298
  opts.once = dataset.saOnce !== 'false';
291
299
  opts.offset = num(dataset.saOffset);
292
300
  opts.stagger = num(dataset.saStagger);
301
+ if (dataset.saProgressVar !== undefined)
302
+ opts.progressVar = normalizeVar(dataset.saProgressVar);
293
303
  if (dataset.saProgress)
294
304
  opts.progressMode = dataset.saProgress.trim() === 'scroll' ? 'scroll' : 'ratio';
295
305
  if (dataset.saParallaxX || dataset.saParallaxY || dataset.saParallaxRotate || dataset.saParallaxScale) {
@@ -325,6 +335,7 @@ function mergeOptions(opts, config) {
325
335
  onLeave: (_q = opts.onLeave) !== null && _q !== void 0 ? _q : noop,
326
336
  onProgress: (_r = opts.onProgress) !== null && _r !== void 0 ? _r : noop,
327
337
  progressMode: (_s = opts.progressMode) !== null && _s !== void 0 ? _s : 'ratio',
338
+ progressVar: opts.progressVar ? normalizeVar(opts.progressVar) : '',
328
339
  };
329
340
  }
330
341
  function resolveTargets(target) {
@@ -361,7 +372,7 @@ function hasParallax(p) {
361
372
  return !!p && Object.keys(p).some((k) => p[k] !== undefined);
362
373
  }
363
374
  function needsProgress(opts) {
364
- return hasParallax(opts.parallax) || opts.onProgress !== noop;
375
+ return hasParallax(opts.parallax) || opts.onProgress !== noop || !!opts.progressVar;
365
376
  }
366
377
  /**
367
378
  * True scroll progress of `el` through the viewport (or `root`): 0 when its top
@@ -622,6 +633,8 @@ function createScrollAnimate(userConfig = {}) {
622
633
  const scrolling = new Set();
623
634
  let frame = 0;
624
635
  let listening = null;
636
+ // Active watch() MutationObservers, disconnected by destroy().
637
+ const watchers = new Set();
625
638
  // Observers are shared between elements with the same root/threshold/rootMargin,
626
639
  // instead of one (or two) IntersectionObservers per element.
627
640
  const pools = new Map();
@@ -656,6 +669,11 @@ function createScrollAnimate(userConfig = {}) {
656
669
  function emitProgress(el, record, progress) {
657
670
  const opts = record.options;
658
671
  opts.onProgress(el, progress);
672
+ if (opts.progressVar) {
673
+ const style = el.style;
674
+ if (style)
675
+ style.setProperty(opts.progressVar, String(+progress.toFixed(4)));
676
+ }
659
677
  if (hasParallax(opts.parallax) && !motionDisabled(config))
660
678
  applyParallax(el, progress, opts.parallax);
661
679
  }
@@ -800,6 +818,10 @@ function createScrollAnimate(userConfig = {}) {
800
818
  registry.set(el, record);
801
819
  attach(el, record, true);
802
820
  }
821
+ function observeDataElement(el) {
822
+ if (!registry.has(el))
823
+ observeElement(el, parseDataAttributes(el, config));
824
+ }
803
825
  const instance = {
804
826
  observe(target, options = {}) {
805
827
  pruneDetached();
@@ -817,12 +839,48 @@ function createScrollAnimate(userConfig = {}) {
817
839
  if (!scope)
818
840
  return;
819
841
  pruneDetached();
820
- scope.querySelectorAll('[data-sa]').forEach((el) => {
821
- if (!registry.has(el))
822
- observeElement(el, parseDataAttributes(el, config));
842
+ scope.querySelectorAll('[data-sa]').forEach(observeDataElement);
843
+ },
844
+ watch(rootElement) {
845
+ const scope = rootElement !== null && rootElement !== void 0 ? rootElement : (hasDOM() ? document : null);
846
+ if (!scope || typeof MutationObserver === 'undefined')
847
+ return noop;
848
+ instance.init(scope);
849
+ const observeTree = (node) => {
850
+ if (node.hasAttribute('data-sa'))
851
+ observeDataElement(node);
852
+ node.querySelectorAll('[data-sa]').forEach(observeDataElement);
853
+ };
854
+ const mo = new MutationObserver((records) => {
855
+ let removed = false;
856
+ records.forEach((record) => {
857
+ if (record.type === 'attributes') {
858
+ const target = record.target;
859
+ if (target.isConnected !== false)
860
+ observeTree(target);
861
+ return;
862
+ }
863
+ record.addedNodes.forEach((node) => {
864
+ if (node instanceof Element && node.isConnected !== false)
865
+ observeTree(node);
866
+ });
867
+ if (record.removedNodes.length)
868
+ removed = true;
869
+ });
870
+ // Free elements that left the DOM (they can't animate any more).
871
+ if (removed)
872
+ pruneDetached();
823
873
  });
874
+ mo.observe(scope, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-sa'] });
875
+ watchers.add(mo);
876
+ return () => {
877
+ mo.disconnect();
878
+ watchers.delete(mo);
879
+ };
824
880
  },
825
881
  destroy() {
882
+ watchers.forEach((mo) => mo.disconnect());
883
+ watchers.clear();
826
884
  registry.forEach((record, el) => {
827
885
  if (!record.animated)
828
886
  reveal(el, config);
@@ -932,6 +990,12 @@ function staggerChildren(container, options = {}, instance) {
932
990
  return () => {
933
991
  io.disconnect();
934
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
+ }
935
999
  late.forEach((el) => sa.unobserve(el));
936
1000
  late.length = 0;
937
1001
  };
@@ -979,6 +1043,8 @@ function sequence(steps, options = {}) {
979
1043
  let io;
980
1044
  let active = [];
981
1045
  let settle;
1046
+ // Targets hidden while waiting for `trigger`; revealed if cancelled before it fires.
1047
+ let prepared = [];
982
1048
  const controller = {
983
1049
  play() {
984
1050
  controller.cancel();
@@ -1010,6 +1076,8 @@ function sequence(steps, options = {}) {
1010
1076
  cancel() {
1011
1077
  io === null || io === void 0 ? void 0 : io.disconnect();
1012
1078
  io = undefined;
1079
+ prepared.forEach((el) => stopAnimation(el));
1080
+ prepared = [];
1013
1081
  active.forEach(({ el }) => stopAnimation(el));
1014
1082
  active = [];
1015
1083
  settle === null || settle === void 0 ? void 0 : settle();
@@ -1021,12 +1089,14 @@ function sequence(steps, options = {}) {
1021
1089
  if (trigger && hasDOM() && supportsObserver()) {
1022
1090
  const el = resolveTargets(trigger)[0];
1023
1091
  if (el) {
1024
- plan(steps, defaults).forEach(({ el: target }) => prepareElement(target));
1092
+ prepared = plan(steps, defaults).map(({ el: target }) => target);
1093
+ prepared.forEach((target) => prepareElement(target));
1025
1094
  io = new IntersectionObserver((entries) => {
1026
1095
  if (!entries.some((e) => e.isIntersecting))
1027
1096
  return;
1028
1097
  io === null || io === void 0 ? void 0 : io.disconnect();
1029
1098
  io = undefined;
1099
+ prepared = []; // play() takes over from here
1030
1100
  controller.play();
1031
1101
  }, { threshold: (_a = defaults.threshold) !== null && _a !== void 0 ? _a : 0.1, rootMargin: (_b = defaults.rootMargin) !== null && _b !== void 0 ? _b : '0px' });
1032
1102
  io.observe(el);