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 +12 -0
- package/README.md +40 -1
- package/dist/index.d.mts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.esm.js +75 -5
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +75 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +75 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +2 -2
- package/dist/index.umd.js.map +1 -1
- package/dist/types/types.d.ts +14 -0
- package/package.json +1 -1
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(
|
|
821
|
-
|
|
822
|
-
|
|
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).
|
|
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);
|