rune-scroller 3.0.1 → 3.0.3

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Rune Scroller — scroll animation styles
3
+ * CSS custom properties for flexible animation control
4
+ * Supports both native and AOS-compatible animation names
5
+ */
6
+
7
+ /* ===== Distance variable ===== */
8
+ :root {
9
+ --rs-distance: 100px;
10
+ }
11
+
12
+ /* ===== Base animation container ===== */
13
+ [data-animation] {
14
+ opacity: 0;
15
+ transition-property: opacity, transform;
16
+ transition-duration: var(--duration, 400ms);
17
+ transition-delay: var(--delay, 0ms);
18
+ transition-timing-function: var(--easing, ease);
19
+ transform: translate3d(var(--tx, 0), var(--ty, 0), 0) scale(var(--scale, 1))
20
+ rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg))
21
+ rotate(var(--rotate, 0deg));
22
+ }
23
+
24
+ /* ===== Visible state ===== */
25
+ [data-animation].is-visible {
26
+ opacity: 1 !important;
27
+ will-change: transform, opacity;
28
+ transform: translate3d(0, 0, 0) scale(1) rotateX(0deg) rotateY(0deg)
29
+ rotate(0deg);
30
+ }
31
+
32
+ /* ===== Fade animations ===== */
33
+ [data-animation="fade"] {
34
+ --tx: 0;
35
+ --ty: 0;
36
+ }
37
+ [data-animation="fade-up"] {
38
+ --ty: var(--rs-distance);
39
+ }
40
+ [data-animation="fade-down"] {
41
+ --ty: calc(-1 * var(--rs-distance));
42
+ }
43
+ [data-animation="fade-left"] {
44
+ --tx: calc(-1 * var(--rs-distance));
45
+ }
46
+ [data-animation="fade-right"] {
47
+ --tx: var(--rs-distance);
48
+ }
49
+ [data-animation="fade-up-right"] {
50
+ --tx: var(--rs-distance);
51
+ --ty: var(--rs-distance);
52
+ }
53
+ [data-animation="fade-up-left"] {
54
+ --tx: calc(-1 * var(--rs-distance));
55
+ --ty: var(--rs-distance);
56
+ }
57
+ [data-animation="fade-down-right"] {
58
+ --tx: var(--rs-distance);
59
+ --ty: calc(-1 * var(--rs-distance));
60
+ }
61
+ [data-animation="fade-down-left"] {
62
+ --tx: calc(-1 * var(--rs-distance));
63
+ --ty: calc(-1 * var(--rs-distance));
64
+ }
65
+
66
+ /* ===== Zoom animations ===== */
67
+ [data-animation="zoom-in"] {
68
+ --scale: 0.6;
69
+ }
70
+ [data-animation="zoom-in-up"] {
71
+ --scale: 0.6;
72
+ --ty: var(--rs-distance);
73
+ }
74
+ [data-animation="zoom-in-down"] {
75
+ --scale: 0.6;
76
+ --ty: calc(-1 * var(--rs-distance));
77
+ }
78
+ [data-animation="zoom-in-left"] {
79
+ --scale: 0.6;
80
+ --tx: calc(-1 * var(--rs-distance));
81
+ }
82
+ [data-animation="zoom-in-right"] {
83
+ --scale: 0.6;
84
+ --tx: var(--rs-distance);
85
+ }
86
+
87
+ [data-animation="zoom-out"] {
88
+ --scale: 1.2;
89
+ }
90
+ [data-animation="zoom-out-up"] {
91
+ --scale: 1.2;
92
+ --ty: var(--rs-distance);
93
+ }
94
+ [data-animation="zoom-out-down"] {
95
+ --scale: 1.2;
96
+ --ty: calc(-1 * var(--rs-distance));
97
+ }
98
+ [data-animation="zoom-out-left"] {
99
+ --scale: 1.2;
100
+ --tx: calc(-1 * var(--rs-distance));
101
+ }
102
+ [data-animation="zoom-out-right"] {
103
+ --scale: 1.2;
104
+ --tx: var(--rs-distance);
105
+ }
106
+
107
+ /* ===== Slide animations ===== */
108
+ [data-animation="slide-up"] {
109
+ opacity: 1;
110
+ transform: translate3d(0, 100%, 0);
111
+ }
112
+ [data-animation="slide-down"] {
113
+ opacity: 1;
114
+ transform: translate3d(0, -100%, 0);
115
+ }
116
+ [data-animation="slide-left"] {
117
+ opacity: 1;
118
+ transform: translate3d(100%, 0, 0);
119
+ }
120
+ [data-animation="slide-right"] {
121
+ opacity: 1;
122
+ transform: translate3d(-100%, 0, 0);
123
+ }
124
+ [data-animation="slide-up"].is-visible,
125
+ [data-animation="slide-down"].is-visible,
126
+ [data-animation="slide-left"].is-visible,
127
+ [data-animation="slide-right"].is-visible {
128
+ transform: translate3d(0, 0, 0);
129
+ }
130
+
131
+ /* ===== Flip animations ===== */
132
+ [data-animation="flip-left"] {
133
+ opacity: 1;
134
+ backface-visibility: hidden;
135
+ transform: perspective(2500px) rotateY(-100deg);
136
+ }
137
+ [data-animation="flip-right"] {
138
+ opacity: 1;
139
+ backface-visibility: hidden;
140
+ transform: perspective(2500px) rotateY(100deg);
141
+ }
142
+ [data-animation="flip-up"] {
143
+ opacity: 1;
144
+ backface-visibility: hidden;
145
+ transform: perspective(2500px) rotateX(-100deg);
146
+ }
147
+ [data-animation="flip-down"] {
148
+ opacity: 1;
149
+ backface-visibility: hidden;
150
+ transform: perspective(2500px) rotateX(100deg);
151
+ }
152
+ [data-animation="flip-left"].is-visible {
153
+ transform: perspective(2500px) rotateY(0);
154
+ }
155
+ [data-animation="flip-right"].is-visible {
156
+ transform: perspective(2500px) rotateY(0);
157
+ }
158
+ [data-animation="flip-up"].is-visible {
159
+ transform: perspective(2500px) rotateX(0);
160
+ }
161
+ [data-animation="flip-down"].is-visible {
162
+ transform: perspective(2500px) rotateX(0);
163
+ }
164
+
165
+ /* ===== Special animations ===== */
166
+ [data-animation="slide-rotate"] {
167
+ --tx: calc(-1 * var(--rs-distance));
168
+ --rotate: -45deg;
169
+ }
170
+
171
+ /* Bounce — uses keyframes */
172
+ [data-animation="bounce-in"] {
173
+ --scale: 0;
174
+ }
175
+
176
+ @keyframes rs-bounce {
177
+ 0% {
178
+ transform: scale(0);
179
+ }
180
+ 50% {
181
+ transform: scale(1.1);
182
+ }
183
+ 100% {
184
+ transform: scale(1);
185
+ }
186
+ }
187
+
188
+ [data-animation="bounce-in"].is-visible {
189
+ animation: rs-bounce var(--duration, 1500ms)
190
+ cubic-bezier(0.68, -0.55, 0.265, 1.55);
191
+ animation-delay: var(--delay, 0ms);
192
+ }
193
+
194
+ /* ===== Legacy aliases (backward compat with v2.x) ===== */
195
+ [data-animation="fade-in"] {
196
+ --tx: 0;
197
+ --ty: 0;
198
+ }
199
+ [data-animation="fade-in-up"] {
200
+ --ty: var(--rs-distance);
201
+ }
202
+ [data-animation="fade-in-down"] {
203
+ --ty: calc(-1 * var(--rs-distance));
204
+ }
205
+ [data-animation="fade-in-left"] {
206
+ --tx: calc(-1 * var(--rs-distance));
207
+ }
208
+ [data-animation="fade-in-right"] {
209
+ --tx: var(--rs-distance);
210
+ }
211
+ [data-animation="flip"] {
212
+ backface-visibility: hidden;
213
+ --ry: 90deg;
214
+ }
215
+ [data-animation="flip"].is-visible {
216
+ --ry: 0deg;
217
+ }
218
+ [data-animation="flip-x"] {
219
+ backface-visibility: hidden;
220
+ --rx: 90deg;
221
+ }
222
+ [data-animation="flip-x"].is-visible {
223
+ --rx: 0deg;
224
+ }
225
+
226
+ /* ===== Accessibility: Respect user's motion preferences ===== */
227
+ @media (prefers-reduced-motion: reduce) {
228
+ [data-animation] {
229
+ transition: none;
230
+ animation: none !important;
231
+ }
232
+
233
+ [data-animation].is-visible {
234
+ opacity: 1;
235
+ transform: none !important;
236
+ }
237
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
3
+ *
4
+ * @param {number} [offset] - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
5
+ * @param {string} [rootMargin] - Custom rootMargin string (takes precedence over offset)
6
+ * @returns {string} rootMargin string for IntersectionObserver
7
+ */
8
+ export function calculateRootMargin(offset?: number, rootMargin?: string): string;
9
+ /**
10
+ * Animation utilities
11
+ * Type definitions have been moved to types.js for single source of truth
12
+ */
13
+ /**
14
+ * All available animation types in the library
15
+ * Includes both native rune-scroller animations and AOS-compatible names
16
+ * @type {readonly string[]}
17
+ */
18
+ export const ANIMATION_TYPES: readonly string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Animation utilities
3
+ * Type definitions have been moved to types.js for single source of truth
4
+ */
5
+
6
+ /**
7
+ * All available animation types in the library
8
+ * Includes both native rune-scroller animations and AOS-compatible names
9
+ * @type {readonly string[]}
10
+ */
11
+ export const ANIMATION_TYPES = [
12
+ // Fade (10)
13
+ "fade",
14
+ "fade-up",
15
+ "fade-down",
16
+ "fade-left",
17
+ "fade-right",
18
+ "fade-up-right",
19
+ "fade-up-left",
20
+ "fade-down-right",
21
+ "fade-down-left",
22
+ // Zoom (10)
23
+ "zoom-in",
24
+ "zoom-in-up",
25
+ "zoom-in-down",
26
+ "zoom-in-left",
27
+ "zoom-in-right",
28
+ "zoom-out",
29
+ "zoom-out-up",
30
+ "zoom-out-down",
31
+ "zoom-out-left",
32
+ "zoom-out-right",
33
+ // Slide (4)
34
+ "slide-up",
35
+ "slide-down",
36
+ "slide-left",
37
+ "slide-right",
38
+ // Flip (4)
39
+ "flip-left",
40
+ "flip-right",
41
+ "flip-up",
42
+ "flip-down",
43
+ // Special (2)
44
+ "slide-rotate",
45
+ "bounce-in",
46
+ // Legacy aliases (v2.x backward compat)
47
+ "fade-in",
48
+ "fade-in-up",
49
+ "fade-in-down",
50
+ "fade-in-left",
51
+ "fade-in-right",
52
+ "flip",
53
+ "flip-x",
54
+ ];
55
+
56
+ /**
57
+ * Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
58
+ *
59
+ * @param {number} [offset] - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
60
+ * @param {string} [rootMargin] - Custom rootMargin string (takes precedence over offset)
61
+ * @returns {string} rootMargin string for IntersectionObserver
62
+ */
63
+ export function calculateRootMargin(offset, rootMargin) {
64
+ return (
65
+ rootMargin ??
66
+ (offset !== undefined
67
+ ? `-${100 - offset}% 0px -${offset}% 0px`
68
+ : "-10% 0px -10% 0px")
69
+ );
70
+ }
package/dist/aos.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ declare namespace _default {
2
+ export { init };
3
+ export { refresh };
4
+ export { refreshHard };
5
+ export { disable };
6
+ }
7
+ export default _default;
8
+ export type AOSOptions = {
9
+ offset?: number;
10
+ delay?: number;
11
+ duration?: number;
12
+ easing?: string;
13
+ once?: boolean;
14
+ mirror?: boolean;
15
+ anchorPlacement?: string;
16
+ disable?: boolean | string;
17
+ useClassNames?: boolean;
18
+ startEvent?: string;
19
+ animatedClassName?: string;
20
+ initClassName?: string;
21
+ };
22
+ /**
23
+ * Initialize AOS compatibility mode
24
+ * @param {AOSOptions} [settings]
25
+ */
26
+ export function init(settings?: AOSOptions): void;
27
+ /**
28
+ * Soft refresh — recalculate positions (no-op for IntersectionObserver)
29
+ */
30
+ export function refresh(): void;
31
+ /**
32
+ * Hard refresh — destroy and re-process all elements
33
+ */
34
+ export function refreshHard(): void;
35
+ /**
36
+ * Disable — remove all AOS attributes and classes
37
+ */
38
+ export function disable(): void;
package/dist/aos.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * AOS compatibility layer for rune-scroller
3
+ *
4
+ * Drop-in replacement for AOS (Animate On Scroll).
5
+ * Supports the same data attributes and init() API.
6
+ *
7
+ * Usage:
8
+ * import { init, refresh, refreshHard } from 'rune-scroller/aos'
9
+ * init()
10
+ *
11
+ * Or as AOS drop-in:
12
+ * import AOS from 'rune-scroller/aos'
13
+ * AOS.init()
14
+ */
15
+
16
+ import { runeScroller } from "./runeScroller.js";
17
+ import { ANIMATION_TYPES } from "./animations.js";
18
+
19
+ /** @typedef {{ offset?: number, delay?: number, duration?: number, easing?: string, once?: boolean, mirror?: boolean, anchorPlacement?: string, disable?: boolean | string, useClassNames?: boolean, startEvent?: string, animatedClassName?: string, initClassName?: string }} AOSOptions */
20
+
21
+ /**
22
+ * Map old animation names (v2.x) to new names
23
+ * @type {Record<string, string>}
24
+ */
25
+ const LEGACY_MAP = {
26
+ "fade-in": "fade",
27
+ "fade-in-up": "fade-up",
28
+ "fade-in-down": "fade-down",
29
+ "fade-in-left": "fade-left",
30
+ "fade-in-right": "fade-right",
31
+ flip: "flip-left",
32
+ "flip-x": "flip-up",
33
+ };
34
+
35
+ /**
36
+ * Normalize animation name (resolve legacy + validate)
37
+ * @param {string} name
38
+ * @returns {string}
39
+ */
40
+ function resolveAnimation(name) {
41
+ if (LEGACY_MAP[name]) return LEGACY_MAP[name];
42
+ if (ANIMATION_TYPES.includes(name)) return name;
43
+ // Unknown animation — try as-is, CSS will silently ignore
44
+ return name;
45
+ }
46
+
47
+ /** @type {AOSOptions} */
48
+ let options = {
49
+ offset: 120,
50
+ delay: 0,
51
+ duration: 400,
52
+ easing: "ease",
53
+ once: false,
54
+ mirror: false,
55
+ anchorPlacement: "top-bottom",
56
+ disable: false,
57
+ useClassNames: false,
58
+ startEvent: "DOMContentLoaded",
59
+ animatedClassName: "aos-animate",
60
+ initClassName: "aos-init",
61
+ };
62
+
63
+ /** @type {Array<{ destroy: () => void }>} */
64
+ let activeActions = [];
65
+
66
+ /** @type {MutationObserver | null} */
67
+ let mutationObserver = null;
68
+
69
+ /** @type {boolean} */
70
+ let initialized = false;
71
+
72
+ /**
73
+ * Read a data-aos-* attribute from an element
74
+ * @param {HTMLElement} el
75
+ * @param {string} key
76
+ * @param {*} fallback
77
+ * @returns {*}
78
+ */
79
+ function getInlineOption(el, key, fallback) {
80
+ const attr = el.getAttribute("data-aos-" + key);
81
+ if (attr === "true") return true;
82
+ if (attr === "false") return false;
83
+ return attr || fallback;
84
+ }
85
+
86
+ /**
87
+ * Apply rune-scroller action to a single element
88
+ * @param {HTMLElement} el
89
+ */
90
+ function applyToElement(el) {
91
+ const animation = resolveAnimation(el.getAttribute("data-aos") || "fade-up");
92
+
93
+ const duration = Number(getInlineOption(el, "duration", options.duration));
94
+ const delay = Number(getInlineOption(el, "delay", options.delay));
95
+ const offset = Number(getInlineOption(el, "offset", options.offset));
96
+ const once = getInlineOption(el, "once", options.once);
97
+ const mirror = getInlineOption(el, "mirror", options.mirror);
98
+
99
+ // Set easing as CSS variable
100
+ if (options.easing || el.getAttribute("data-aos-easing")) {
101
+ const easing = getInlineOption(el, "easing", options.easing);
102
+ el.style.setProperty("--easing", easing);
103
+ }
104
+
105
+ // Add init class
106
+ if (options.initClassName) {
107
+ el.classList.add(options.initClassName);
108
+ }
109
+
110
+ // Use useClassNames to add animation name as extra class
111
+ if (options.useClassNames && animation) {
112
+ el.classList.add(animation);
113
+ }
114
+
115
+ // Apply runeScroller action
116
+ const action = runeScroller(el, {
117
+ animation,
118
+ duration,
119
+ offset: offset - 120, // AOS offset is "px from viewport bottom", we adjust
120
+ repeat: !once || mirror,
121
+ });
122
+
123
+ // Set delay CSS variable AFTER runeScroller (which sets --delay: 0ms)
124
+ el.style.setProperty("--delay", `${delay}ms`);
125
+
126
+ activeActions.push(action);
127
+ }
128
+
129
+ /**
130
+ * Process all [data-aos] elements in the DOM
131
+ */
132
+ function processElements() {
133
+ /** @type {NodeListOf<HTMLElement>} */
134
+ const elements = document.querySelectorAll("[data-aos]");
135
+ elements.forEach(applyToElement);
136
+ }
137
+
138
+ /**
139
+ * Watch for new [data-aos] elements added to the DOM
140
+ */
141
+ function observeMutations() {
142
+ if (mutationObserver) mutationObserver.disconnect();
143
+
144
+ mutationObserver = new MutationObserver((mutations) => {
145
+ let hasNewAOS = false;
146
+
147
+ for (const mutation of mutations) {
148
+ for (const node of mutation.addedNodes) {
149
+ if (node instanceof HTMLElement) {
150
+ if (node.hasAttribute && node.hasAttribute("data-aos")) {
151
+ hasNewAOS = true;
152
+ }
153
+ if (node.querySelectorAll) {
154
+ const aosChildren = node.querySelectorAll("[data-aos]");
155
+ if (aosChildren.length > 0) hasNewAOS = true;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ if (hasNewAOS) {
162
+ refreshHard();
163
+ }
164
+ });
165
+
166
+ mutationObserver.observe(document.documentElement, {
167
+ childList: true,
168
+ subtree: true,
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Initialize AOS compatibility mode
174
+ * @param {AOSOptions} [settings]
175
+ */
176
+ function init(settings = {}) {
177
+ if (typeof window === "undefined") return;
178
+
179
+ Object.assign(options, settings);
180
+
181
+ // Set global easing on body for CSS
182
+ const body = document.querySelector("body");
183
+ if (body) {
184
+ body.setAttribute("data-aos-easing", options.easing);
185
+ body.setAttribute("data-aos-duration", String(options.duration));
186
+ body.setAttribute("data-aos-delay", String(options.delay));
187
+ }
188
+
189
+ // Process elements on start event or immediately
190
+ const startEvent = options.startEvent || "DOMContentLoaded";
191
+
192
+ if (
193
+ startEvent === "DOMContentLoaded" &&
194
+ ["complete", "interactive"].includes(document.readyState)
195
+ ) {
196
+ processElements();
197
+ observeMutations();
198
+ initialized = true;
199
+ } else if (startEvent === "load") {
200
+ window.addEventListener("load", () => {
201
+ processElements();
202
+ observeMutations();
203
+ initialized = true;
204
+ });
205
+ } else {
206
+ document.addEventListener(startEvent, () => {
207
+ processElements();
208
+ observeMutations();
209
+ initialized = true;
210
+ });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Soft refresh — recalculate positions (no-op for IntersectionObserver)
216
+ */
217
+ function refresh() {
218
+ // IntersectionObserver handles position automatically
219
+ // Only refresh if initialized
220
+ if (!initialized) return;
221
+ }
222
+
223
+ /**
224
+ * Hard refresh — destroy and re-process all elements
225
+ */
226
+ function refreshHard() {
227
+ // Destroy all active actions
228
+ activeActions.forEach((action) => {
229
+ try {
230
+ action.destroy();
231
+ } catch {
232
+ /* ignore */
233
+ }
234
+ });
235
+ activeActions = [];
236
+
237
+ // Remove init classes
238
+ if (options.initClassName) {
239
+ document
240
+ .querySelectorAll(`[data-aos].${options.initClassName}`)
241
+ .forEach((el) => el.classList.remove(options.initClassName));
242
+ }
243
+
244
+ processElements();
245
+ }
246
+
247
+ /**
248
+ * Disable — remove all AOS attributes and classes
249
+ */
250
+ function disable() {
251
+ activeActions.forEach((action) => {
252
+ try {
253
+ action.destroy();
254
+ } catch {
255
+ /* ignore */
256
+ }
257
+ });
258
+ activeActions = [];
259
+
260
+ document.querySelectorAll("[data-aos]").forEach((el) => {
261
+ el.removeAttribute("data-aos");
262
+ el.removeAttribute("data-aos-easing");
263
+ el.removeAttribute("data-aos-duration");
264
+ el.removeAttribute("data-aos-delay");
265
+ el.removeAttribute("data-aos-offset");
266
+
267
+ if (options.initClassName) el.classList.remove(options.initClassName);
268
+ });
269
+ }
270
+
271
+ // Public API — compatible with AOS
272
+ export default { init, refresh, refreshHard, disable };
273
+ export { init, refresh, refreshHard, disable };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @param {HTMLElement} element
3
+ * @param {number} [duration]
4
+ * @param {number} [delay=0]
5
+ */
6
+ export function setCSSVariables(element: HTMLElement, duration?: number, delay?: number): void;
7
+ /**
8
+ * @param {HTMLElement} element
9
+ * @param {import('./types.js').AnimationType} animation
10
+ */
11
+ export function setupAnimationElement(element: HTMLElement, animation: import("./types.js").AnimationType): void;
12
+ /**
13
+ * @param {HTMLElement} element
14
+ * @param {boolean} [debug=false]
15
+ * @param {number} [offset=0]
16
+ * @param {string} [sentinelColor='#00e0ff']
17
+ * @param {string} [debugLabel]
18
+ * @param {string} [sentinelId]
19
+ * @returns {{ element: HTMLElement, id: string }}
20
+ */
21
+ export function createSentinel(element: HTMLElement, debug?: boolean, offset?: number, sentinelColor?: string, debugLabel?: string, sentinelId?: string): {
22
+ element: HTMLElement;
23
+ id: string;
24
+ };
25
+ /**
26
+ * Check if CSS animations are loaded and warn if not (dev only)
27
+ * Uses cache to avoid expensive getComputedStyle() on every element creation
28
+ * @returns {boolean} True if CSS appears to be loaded
29
+ */
30
+ export function checkAndWarnIfCSSNotLoaded(): boolean;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Global counter for auto-generating sentinel IDs
3
+ * @type {number}
4
+ */
5
+ let sentinelCounter = 0;
6
+
7
+ /**
8
+ * Cache to check CSS only once per page load
9
+ * Avoids expensive getComputedStyle() calls
10
+ * @type {boolean | null}
11
+ */
12
+ let cssCheckResult = null;
13
+
14
+ /**
15
+ * @param {HTMLElement} element
16
+ * @param {number} [duration]
17
+ * @param {number} [delay=0]
18
+ */
19
+ export function setCSSVariables(element, duration, delay = 0) {
20
+ if (duration !== undefined) {
21
+ element.style.setProperty("--duration", `${duration}ms`);
22
+ }
23
+ element.style.setProperty("--delay", `${delay}ms`);
24
+ }
25
+
26
+ /**
27
+ * @param {HTMLElement} element
28
+ * @param {import('./types.js').AnimationType} animation
29
+ */
30
+ export function setupAnimationElement(element, animation) {
31
+ element.classList.add("scroll-animate");
32
+ element.setAttribute("data-animation", animation);
33
+ }
34
+
35
+ /**
36
+ * @param {HTMLElement} element
37
+ * @param {boolean} [debug=false]
38
+ * @param {number} [offset=0]
39
+ * @param {string} [sentinelColor='#00e0ff']
40
+ * @param {string} [debugLabel]
41
+ * @param {string} [sentinelId]
42
+ * @returns {{ element: HTMLElement, id: string }}
43
+ */
44
+ export function createSentinel(
45
+ element,
46
+ debug = false,
47
+ offset = 0,
48
+ sentinelColor = "#00e0ff",
49
+ debugLabel = "",
50
+ sentinelId,
51
+ ) {
52
+ const sentinel = document.createElement("div");
53
+ // Use offsetHeight instead of getBoundingClientRect for accurate dimensions
54
+ // getBoundingClientRect returns transformed dimensions (affected by scale, etc)
55
+ // offsetHeight returns actual element height independent of CSS transforms
56
+ const elementHeight = element.offsetHeight;
57
+ const sentinelTop = elementHeight + offset;
58
+
59
+ // Generate auto-ID if not provided
60
+ if (!sentinelId) {
61
+ sentinelCounter++;
62
+ sentinelId = `sentinel-${sentinelCounter}`;
63
+ }
64
+
65
+ // Always set to data-sentinel-id attribute
66
+ sentinel.setAttribute("data-sentinel-id", sentinelId);
67
+
68
+ if (debug) {
69
+ sentinel.style.cssText = `position:absolute;top:${sentinelTop}px;left:0;width:100%;height:3px;background:${sentinelColor};margin:0;padding:2px 4px;box-sizing:border-box;z-index:999;pointer-events:none;display:flex;align-items:center;font-size:10px;color:#000;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis`;
70
+ sentinel.setAttribute("data-sentinel-debug", "true");
71
+ // Show ID in debug mode (or debugLabel if provided)
72
+ if (debugLabel) {
73
+ sentinel.textContent = debugLabel;
74
+ } else {
75
+ sentinel.textContent = sentinelId;
76
+ }
77
+ } else {
78
+ sentinel.style.cssText = `position:absolute;top:${sentinelTop}px;left:0;width:100%;height:1px;visibility:hidden;margin:0;padding:0;box-sizing:border-box;pointer-events:none`;
79
+ }
80
+
81
+ return { element: sentinel, id: sentinelId };
82
+ }
83
+
84
+ /**
85
+ * Check if CSS animations are loaded and warn if not (dev only)
86
+ * Uses cache to avoid expensive getComputedStyle() on every element creation
87
+ * @returns {boolean} True if CSS appears to be loaded
88
+ */
89
+ export function checkAndWarnIfCSSNotLoaded() {
90
+ if (typeof document === "undefined") return false;
91
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return true;
92
+
93
+ // Return cached result if already checked (avoids expensive reflows)
94
+ if (cssCheckResult !== null) return cssCheckResult;
95
+
96
+ // Try to detect if animations.css is loaded by checking for animation classes
97
+ const test = document.createElement("div");
98
+ test.className = "scroll-animate is-visible";
99
+ test.style.position = "absolute";
100
+ test.style.opacity = "0";
101
+ document.body.appendChild(test);
102
+ const computed = getComputedStyle(test);
103
+ const hasAnimation =
104
+ computed.animation !== "none" && computed.animation !== "";
105
+ test.remove();
106
+
107
+ if (!hasAnimation) {
108
+ console.warn(
109
+ "[rune-scroller] CSS animations not found. Make sure to import the animations:\n" +
110
+ ' import "rune-scroller/animations.css";\n' +
111
+ "Documentation: https://github.com/lelabdev/rune-scroller#installation",
112
+ );
113
+ }
114
+
115
+ // Cache the result for future calls
116
+ cssCheckResult = hasAnimation;
117
+ return hasAnimation;
118
+ }
@@ -0,0 +1,5 @@
1
+ export default runeScroller;
2
+ import { runeScroller } from "./runeScroller.js";
3
+ export { runeScroller, runeScroller as rs };
4
+ export { useIntersection, useIntersectionOnce } from "./useIntersection.svelte.js";
5
+ export { calculateRootMargin, ANIMATION_TYPES } from "./animations.js";
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Rune Scroller - Lightweight scroll animations for Svelte 5
3
+ *
4
+ * Main entry point exporting all public APIs
5
+ *
6
+ * @module rune-scroller
7
+ */
8
+
9
+ // Note: CSS must be imported separately by the user:
10
+ // import 'rune-scroller/animations.css'
11
+ // This avoids SSR issues with automatic CSS imports in Node/edge runtimes.
12
+
13
+ // Main action (default export - recommended)
14
+ import { runeScroller } from "./runeScroller.js";
15
+ export default runeScroller;
16
+ export { runeScroller };
17
+ export { runeScroller as rs };
18
+
19
+ // Composables
20
+ export {
21
+ useIntersection,
22
+ useIntersectionOnce,
23
+ } from "./useIntersection.svelte.js";
24
+
25
+ // Utilities
26
+ export { calculateRootMargin, ANIMATION_TYPES } from "./animations.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared IntersectionObserver utility functions
3
+ * Reduces code duplication between action implementations
4
+ */
5
+ /**
6
+ * @param {HTMLElement} target
7
+ * @param {IntersectionObserverCallback} callback
8
+ * @param {IntersectionObserverInit} options
9
+ * @returns {{ observer: IntersectionObserver, isConnected: boolean }}
10
+ */
11
+ export function createManagedObserver(target: HTMLElement, callback: IntersectionObserverCallback, options: IntersectionObserverInit): {
12
+ observer: IntersectionObserver;
13
+ isConnected: boolean;
14
+ };
15
+ /**
16
+ * @param {IntersectionObserver} observer
17
+ * @param {{ isConnected: boolean }} state
18
+ */
19
+ export function disconnectObserver(observer: IntersectionObserver, state: {
20
+ isConnected: boolean;
21
+ }): void;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared IntersectionObserver utility functions
3
+ * Reduces code duplication between action implementations
4
+ */
5
+
6
+ /**
7
+ * @param {HTMLElement} target
8
+ * @param {IntersectionObserverCallback} callback
9
+ * @param {IntersectionObserverInit} options
10
+ * @returns {{ observer: IntersectionObserver, isConnected: boolean }}
11
+ */
12
+ export function createManagedObserver(target, callback, options) {
13
+ const observer = new IntersectionObserver(callback, options);
14
+ observer.observe(target);
15
+
16
+ return {
17
+ observer,
18
+ isConnected: true,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * @param {IntersectionObserver} observer
24
+ * @param {{ isConnected: boolean }} state
25
+ */
26
+ export function disconnectObserver(observer, state) {
27
+ if (state.isConnected && observer) {
28
+ observer.disconnect();
29
+ state.isConnected = false;
30
+ }
31
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @param {HTMLElement} element
3
+ * @param {import('./types.js').RuneScrollerOptions} [options]
4
+ * @returns {{ update: (newOptions?: import('./types.js').RuneScrollerOptions) => void, destroy: () => void }}
5
+ */
6
+ export function runeScroller(element: HTMLElement, options?: import("./types.js").RuneScrollerOptions): {
7
+ update: (newOptions?: import("./types.js").RuneScrollerOptions) => void;
8
+ destroy: () => void;
9
+ };
@@ -0,0 +1,184 @@
1
+ import {
2
+ setCSSVariables,
3
+ setupAnimationElement,
4
+ createSentinel,
5
+ checkAndWarnIfCSSNotLoaded,
6
+ } from "./dom-utils.js";
7
+ import { createManagedObserver, disconnectObserver } from "./observer-utils.js";
8
+ import { ANIMATION_TYPES } from "./animations.js";
9
+
10
+ /**
11
+ * @param {HTMLElement} element
12
+ * @param {import('./types.js').RuneScrollerOptions} [options]
13
+ * @returns {{ update: (newOptions?: import('./types.js').RuneScrollerOptions) => void, destroy: () => void }}
14
+ */
15
+ export function runeScroller(element, options) {
16
+ // SSR Guard: Return no-op action when running on server
17
+ if (typeof window === "undefined") {
18
+ return {
19
+ update: () => {},
20
+ destroy: () => {},
21
+ };
22
+ }
23
+
24
+ // Warn if CSS is not loaded (first time only)
25
+ if (typeof document !== "undefined") {
26
+ checkAndWarnIfCSSNotLoaded();
27
+ }
28
+
29
+ // Validate animation type
30
+ let animation = options?.animation ?? "fade-in";
31
+ if (animation && !ANIMATION_TYPES.includes(animation)) {
32
+ if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
33
+ console.warn(
34
+ `[rune-scroller] Invalid animation "${animation}". Using "fade-in" instead. ` +
35
+ `Valid options: ${ANIMATION_TYPES.join(", ")}`,
36
+ );
37
+ }
38
+ animation = "fade-in";
39
+ }
40
+
41
+ // CSS handles initial opacity via [data-animation] { opacity: 0 }
42
+ // No inline opacity needed — it would override slide animations that use opacity: 1
43
+
44
+ // Setup animation classes and CSS variables
45
+ if (animation) {
46
+ setupAnimationElement(element, animation);
47
+ }
48
+
49
+ // Warn about overflow:hidden in debug mode
50
+ if (options?.debug && element.style.overflow === "hidden") {
51
+ console.warn(
52
+ "[rune-scroller] Element has overflow:hidden — the sentinel indicator may be clipped in debug mode.",
53
+ );
54
+ }
55
+
56
+ // Set CSS variables for duration
57
+ if (options?.duration !== undefined) {
58
+ setCSSVariables(element, options.duration);
59
+ }
60
+
61
+ // Force reflow to ensure transitions are ready
62
+ void element.offsetHeight;
63
+
64
+ // Create a wrapper div around the element to position the sentinel
65
+ const wrapper = document.createElement("div");
66
+ wrapper.style.cssText =
67
+ "position:relative;display:block;width:100%;margin:0;padding:0;box-sizing:border-box";
68
+ element.insertAdjacentElement("beforebegin", wrapper);
69
+ wrapper.appendChild(element);
70
+
71
+ // Create the invisible sentinel (or visible if debug=true)
72
+ // Positioned absolutely relative to the wrapper
73
+ const sentinelResult = createSentinel(
74
+ element,
75
+ options?.debug,
76
+ options?.offset,
77
+ options?.sentinelColor,
78
+ options?.debugLabel,
79
+ options?.sentinelId,
80
+ );
81
+ const sentinel = sentinelResult.element;
82
+ const sentinelId = sentinelResult.id;
83
+
84
+ // Add sentinel ID to element (either provided or auto-generated)
85
+ element.setAttribute("data-sentinel-id", sentinelId);
86
+
87
+ wrapper.appendChild(sentinel);
88
+
89
+ // Observe the sentinel with cleanup tracking
90
+ const state = { isConnected: true };
91
+ let currentSentinel = sentinel;
92
+ let resizeObserver;
93
+ let intersectionObserver;
94
+
95
+ const { observer } = createManagedObserver(
96
+ sentinel,
97
+ (entries) => {
98
+ const isIntersecting = entries[0].isIntersecting;
99
+ if (isIntersecting) {
100
+ // Add the is-visible class to trigger animation
101
+ element.classList.add("is-visible");
102
+ // Call onVisible callback if provided
103
+ options?.onVisible?.(element);
104
+ // Disconnect if not in repeat mode
105
+ if (!options?.repeat) {
106
+ disconnectObserver(intersectionObserver, state);
107
+ }
108
+ } else if (options?.repeat) {
109
+ // In repeat mode, remove the class when the sentinel exits
110
+ element.classList.remove("is-visible");
111
+ }
112
+ },
113
+ { threshold: 0 },
114
+ );
115
+
116
+ intersectionObserver = observer;
117
+
118
+ // Function to recreate sentinel when element is resized
119
+ const recreateSentinel = () => {
120
+ const newSentinelResult = createSentinel(
121
+ element,
122
+ options?.debug,
123
+ options?.offset,
124
+ options?.sentinelColor,
125
+ options?.debugLabel,
126
+ sentinelId,
127
+ );
128
+ const newSentinel = newSentinelResult.element;
129
+ currentSentinel.replaceWith(newSentinel);
130
+ currentSentinel = newSentinel;
131
+ // Update observer to watch the new sentinel
132
+ intersectionObserver.disconnect();
133
+ intersectionObserver.observe(newSentinel);
134
+ };
135
+
136
+ // Setup ResizeObserver to handle element resizing
137
+ if (typeof ResizeObserver !== "undefined") {
138
+ resizeObserver = new ResizeObserver(() => {
139
+ recreateSentinel();
140
+ });
141
+ resizeObserver.observe(element);
142
+ }
143
+
144
+ return {
145
+ update(newOptions) {
146
+ if (newOptions?.animation) {
147
+ element.setAttribute("data-animation", newOptions.animation);
148
+ }
149
+ if (newOptions?.duration) {
150
+ setCSSVariables(element, newOptions.duration);
151
+ }
152
+ // Update repeat option
153
+ if (
154
+ newOptions?.repeat !== undefined &&
155
+ newOptions.repeat !== options?.repeat
156
+ ) {
157
+ options = { ...options, repeat: newOptions.repeat };
158
+ }
159
+ // Update offset and debug if changed
160
+ if (
161
+ (newOptions?.offset !== undefined &&
162
+ newOptions.offset !== options?.offset) ||
163
+ (newOptions?.debug !== undefined && newOptions.debug !== options?.debug)
164
+ ) {
165
+ options = { ...options, ...newOptions };
166
+ recreateSentinel();
167
+ }
168
+ },
169
+ destroy() {
170
+ disconnectObserver(intersectionObserver, state);
171
+ // Cleanup ResizeObserver
172
+ if (resizeObserver) {
173
+ resizeObserver.disconnect();
174
+ }
175
+ currentSentinel.remove();
176
+ // Unwrap element (move it out of wrapper)
177
+ const parent = wrapper.parentElement;
178
+ if (parent) {
179
+ wrapper.insertAdjacentElement("beforebegin", element);
180
+ }
181
+ wrapper.remove();
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Animation type names (includes AOS-compatible names)
3
+ */
4
+ export type AnimationType = "fade" | "fade-up" | "fade-down" | "fade-left" | "fade-right" | "fade-up-right" | "fade-up-left" | "fade-down-right" | "fade-down-left" | "zoom-in" | "zoom-in-up" | "zoom-in-down" | "zoom-in-left" | "zoom-in-right" | "zoom-out" | "zoom-out-up" | "zoom-out-down" | "zoom-out-left" | "zoom-out-right" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "flip-left" | "flip-right" | "flip-up" | "flip-down" | "slide-rotate" | "bounce-in" | "fade-in" | "fade-in-up" | "fade-in-down" | "fade-in-left" | "fade-in-right" | "flip" | "flip-x";
5
+ /**
6
+ * Options for the runeScroller action
7
+ */
8
+ export type RuneScrollerOptions = {
9
+ /**
10
+ * - Animation type to apply
11
+ */
12
+ animation?: AnimationType | undefined;
13
+ /**
14
+ * - Animation duration in milliseconds
15
+ */
16
+ duration?: number | undefined;
17
+ /**
18
+ * - Repeat animation on every scroll
19
+ */
20
+ repeat?: boolean | undefined;
21
+ /**
22
+ * - Show sentinel as visible line for debugging
23
+ */
24
+ debug?: boolean | undefined;
25
+ /**
26
+ * - Sentinel color for debug mode
27
+ */
28
+ sentinelColor?: string | undefined;
29
+ /**
30
+ * - Unique identifier for sentinel
31
+ */
32
+ sentinelId?: string | undefined;
33
+ /**
34
+ * - Debug label to show on sentinel
35
+ */
36
+ debugLabel?: string | undefined;
37
+ /**
38
+ * - Offset of sentinel in pixels (negative = above element)
39
+ */
40
+ offset?: number | undefined;
41
+ /**
42
+ * - CSS timing function
43
+ */
44
+ easing?: string | undefined;
45
+ /**
46
+ * - Callback when animation triggers
47
+ */
48
+ onVisible?: ((element: HTMLElement) => void) | undefined;
49
+ };
50
+ /**
51
+ * Configuration options for IntersectionObserver
52
+ */
53
+ export type IntersectionOptions = {
54
+ /**
55
+ * - IntersectionObserver threshold
56
+ */
57
+ threshold?: number | number[] | undefined;
58
+ /**
59
+ * - Custom margin around root element
60
+ */
61
+ rootMargin?: string | undefined;
62
+ /**
63
+ * - Root element for intersection observation
64
+ */
65
+ root?: Element | null | undefined;
66
+ };
67
+ /**
68
+ * Return type for useIntersection and useIntersectionOnce composables
69
+ */
70
+ export type UseIntersectionReturn = {
71
+ /**
72
+ * - Reference to the DOM element being observed
73
+ */
74
+ element: HTMLElement | null;
75
+ /**
76
+ * - Whether the element is currently visible in viewport
77
+ */
78
+ isVisible: boolean;
79
+ };
package/dist/types.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Centralized type definitions for Rune Scroller library
3
+ */
4
+
5
+ /**
6
+ * Animation type names (includes AOS-compatible names)
7
+ * @typedef {'fade' | 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'fade-up-right' | 'fade-up-left' | 'fade-down-right' | 'fade-down-left' | 'zoom-in' | 'zoom-in-up' | 'zoom-in-down' | 'zoom-in-left' | 'zoom-in-right' | 'zoom-out' | 'zoom-out-up' | 'zoom-out-down' | 'zoom-out-left' | 'zoom-out-right' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'flip-left' | 'flip-right' | 'flip-up' | 'flip-down' | 'slide-rotate' | 'bounce-in' | 'fade-in' | 'fade-in-up' | 'fade-in-down' | 'fade-in-left' | 'fade-in-right' | 'flip' | 'flip-x'} AnimationType
8
+ */
9
+
10
+ /**
11
+ * Options for the runeScroller action
12
+ * @typedef {Object} RuneScrollerOptions
13
+ * @property {AnimationType} [animation='fade-up'] - Animation type to apply
14
+ * @property {number} [duration=400] - Animation duration in milliseconds
15
+ * @property {boolean} [repeat=false] - Repeat animation on every scroll
16
+ * @property {boolean} [debug=false] - Show sentinel as visible line for debugging
17
+ * @property {string} [sentinelColor='#00e0ff'] - Sentinel color for debug mode
18
+ * @property {string} [sentinelId] - Unique identifier for sentinel
19
+ * @property {string} [debugLabel] - Debug label to show on sentinel
20
+ * @property {number} [offset=0] - Offset of sentinel in pixels (negative = above element)
21
+ * @property {string} [easing='ease'] - CSS timing function
22
+ * @property {(element: HTMLElement) => void} [onVisible] - Callback when animation triggers
23
+ */
24
+
25
+ /**
26
+ * Configuration options for IntersectionObserver
27
+ * @typedef {Object} IntersectionOptions
28
+ * @property {number | number[]} [threshold] - IntersectionObserver threshold
29
+ * @property {string} [rootMargin] - Custom margin around root element
30
+ * @property {Element | null} [root] - Root element for intersection observation
31
+ */
32
+
33
+ /**
34
+ * Return type for useIntersection and useIntersectionOnce composables
35
+ * @typedef {Object} UseIntersectionReturn
36
+ * @property {HTMLElement | null} element - Reference to the DOM element being observed
37
+ * @property {boolean} isVisible - Whether the element is currently visible in viewport
38
+ */
39
+
40
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @param {import('./types.js').IntersectionOptions} [options={}]
3
+ * @param {(isVisible: boolean) => void} [onVisible]
4
+ * @returns {import('./types.js').UseIntersectionReturn}
5
+ */
6
+ export function useIntersection(options?: import("./types.js").IntersectionOptions, onVisible?: (isVisible: boolean) => void): import("./types.js").UseIntersectionReturn;
7
+ /**
8
+ * @param {import('./types.js').IntersectionOptions} [options={}]
9
+ * @returns {import('./types.js').UseIntersectionReturn}
10
+ */
11
+ export function useIntersectionOnce(options?: import("./types.js").IntersectionOptions): import("./types.js").UseIntersectionReturn;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Composable for handling IntersectionObserver logic
3
+ * Reduces duplication between animation components
4
+ */
5
+
6
+ /**
7
+ * @param {import('./types.js').IntersectionOptions} [options={}]
8
+ * @param {((entry: IntersectionObserverEntry, isVisible: boolean) => void) | undefined} onIntersect
9
+ * @param {boolean} [once=false]
10
+ * @returns {import('./types.js').UseIntersectionReturn}
11
+ */
12
+ function createIntersectionObserver(
13
+ options = {},
14
+ onIntersect = undefined,
15
+ once = false,
16
+ ) {
17
+ const {
18
+ threshold = 0.5,
19
+ rootMargin = "-10% 0px -10% 0px",
20
+ root = null,
21
+ } = options;
22
+
23
+ let element = $state(null);
24
+ let isVisible = $state(false);
25
+ let hasTriggeredOnce = false;
26
+ /** @type {IntersectionObserver | null} */
27
+ let observer = null;
28
+
29
+ $effect(() => {
30
+ if (!element) return;
31
+
32
+ observer = new IntersectionObserver(
33
+ (entries) => {
34
+ entries.forEach((entry) => {
35
+ // For once-only behavior, check if already triggered
36
+ if (once && hasTriggeredOnce) return;
37
+
38
+ isVisible = entry.isIntersecting;
39
+ if (onIntersect) {
40
+ onIntersect(entry, entry.isIntersecting);
41
+ }
42
+
43
+ // Unobserve after first trigger if once=true
44
+ if (once && entry.isIntersecting) {
45
+ hasTriggeredOnce = true;
46
+ observer?.unobserve(entry.target);
47
+ }
48
+ });
49
+ },
50
+ {
51
+ threshold,
52
+ rootMargin,
53
+ root,
54
+ },
55
+ );
56
+
57
+ observer.observe(element);
58
+
59
+ return () => {
60
+ observer?.disconnect();
61
+ };
62
+ });
63
+
64
+ return {
65
+ get element() {
66
+ return element;
67
+ },
68
+ set element(value) {
69
+ element = value;
70
+ },
71
+ get isVisible() {
72
+ return isVisible;
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * @param {import('./types.js').IntersectionOptions} [options={}]
79
+ * @param {(isVisible: boolean) => void} [onVisible]
80
+ * @returns {import('./types.js').UseIntersectionReturn}
81
+ */
82
+ export function useIntersection(options = {}, onVisible) {
83
+ return createIntersectionObserver(
84
+ options,
85
+ (_entry, isVisible) => {
86
+ onVisible?.(isVisible);
87
+ },
88
+ false,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * @param {import('./types.js').IntersectionOptions} [options={}]
94
+ * @returns {import('./types.js').UseIntersectionReturn}
95
+ */
96
+ export function useIntersectionOnce(options = {}) {
97
+ return createIntersectionObserver(options, () => {}, true);
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rune-scroller",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Lightweight scroll animations for Svelte 5. Drop-in AOS replacement. 30 animations, zero dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,