rune-scroller 2.2.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
  <img src="./logo.png" alt="Rune Scroller Logo" width="200" />
5
5
  </div>
6
6
 
7
- **Lightweight scroll animations for Svelte 5** — Built with Svelte 5 Runes and IntersectionObserver API.
7
+ **Lightweight scroll animations. AOS replacement. Works everywhere.**
8
+
9
+ Built with native IntersectionObserver — zero JS on scroll, GPU-accelerated, ~5.6KB gzipped.
8
10
 
9
11
  > 🚀 **Open Source** by [ludoloops](https://github.com/ludoloops) at [LeLab.dev](https://lelab.dev)
10
12
  > 📜 Licensed under **MIT**
@@ -20,191 +22,263 @@
20
22
 
21
23
  ---
22
24
 
23
- ## Features
24
-
25
- - **5.7KB gzipped** (JS+CSS) - Minimal overhead
26
- - **Zero dependencies** - Pure Svelte 5 + IntersectionObserver
27
- - **14 animations** - Fade, Zoom, Flip, Slide, Bounce
28
- - **TypeScript support** - Full type definitions
29
- - **SSR-ready** - SvelteKit compatible
30
- - **GPU-accelerated** - Pure CSS transforms
31
- - **Accessible** - Respects `prefers-reduced-motion`
32
-
33
- ---
25
+ ## 🚀 Quick Start
34
26
 
35
- ## 📦 Installation
27
+ ### Any framework — Svelte, React, Vue, Angular, Vanilla JS
36
28
 
37
29
  ```bash
38
30
  npm install rune-scroller
39
31
  ```
40
32
 
41
- ---
33
+ ```js
34
+ import AOS from "rune-scroller/aos";
35
+ AOS.init();
36
+ ```
42
37
 
43
- ## 🚀 Quick Start
38
+ ```html
39
+ <div data-aos="fade-up" data-aos-duration="800">Animated</div>
40
+ <div data-aos="zoom-in" data-aos-delay="200">Delayed zoom</div>
41
+ ```
42
+
43
+ That's it. Same API as AOS. Works everywhere.
44
+
45
+ ### Svelte (native action)
44
46
 
45
47
  ```svelte
46
48
  <script>
47
- import runeScroller from 'rune-scroller';
49
+ import rs from 'rune-scroller';
48
50
  </script>
49
51
 
50
- <!-- Simple animation -->
51
- <div use:runeScroller={{ animation: 'fade-in' }}>
52
- <h2>Animated Heading</h2>
53
- </div>
52
+ <div use:rs={{ animation: 'fade-up' }}>Animates on scroll</div>
53
+ ```
54
54
 
55
- <!-- With custom duration -->
56
- <div use:runeScroller={{ animation: 'fade-in-up', duration: 1500 }}>
57
- <div class="card">Smooth fade and slide</div>
58
- </div>
55
+ ### React
59
56
 
60
- <!-- Repeat on every scroll -->
61
- <div use:runeScroller={{ animation: 'bounce-in', repeat: true }}>
62
- <button>Bounces on every scroll</button>
63
- </div>
64
- ```
57
+ ````jsx
58
+ import { useEffect } from "react";
59
+ ### React (not tested — should work)
65
60
 
66
- ---
61
+ ```jsx
62
+ import { useEffect } from 'react';
63
+ import AOS from 'rune-scroller/aos';
67
64
 
68
- ## 🎨 Available Animations
65
+ function App() {
66
+ useEffect(() => { AOS.init(); }, []);
67
+ return (
68
+ <>
69
+ <h1 data-aos="fade-down">Welcome</h1>
70
+ <p data-aos="fade-up" data-aos-delay="200">Subtitle</p>
71
+ </>
72
+ );
73
+ }
74
+ ````
69
75
 
70
- ### Fade (5)
76
+ ### Vue (not tested — should work)
71
77
 
72
- - `fade-in` - Simple opacity fade
73
- - `fade-in-up` - Fade + move up 300px
74
- - `fade-in-down` - Fade + move down 300px
75
- - `fade-in-left` - Fade + move from right 300px
76
- - `fade-in-right` - Fade + move from left 300px
78
+ ```vue
79
+ <script setup>
80
+ import { onMounted } from "vue";
81
+ import AOS from "rune-scroller/aos";
82
+ onMounted(() => AOS.init());
83
+ </script>
77
84
 
78
- ### Zoom (5)
85
+ <template>
86
+ <div data-aos="fade-up">Animated</div>
87
+ </template>
88
+ ```
79
89
 
80
- - `zoom-in` - Scale from 0.3 to 1
81
- - `zoom-out` - Scale from 2 to 1
82
- - `zoom-in-up` - Zoom (0.5→1) + move up 300px
83
- - `zoom-in-left` - Zoom (0.5→1) + move from right 300px
84
- - `zoom-in-right` - Zoom (0.5→1) + move from left 300px
90
+ ### Angular (not tested should work)
85
91
 
86
- ### Others (4)
92
+ ```typescript
93
+ // app.component.ts
94
+ import { Component, OnInit } from "@angular/core";
95
+ import AOS from "rune-scroller/aos";
96
+
97
+ @Component({ selector: "app-root", templateUrl: "./app.component.html" })
98
+ export class AppComponent implements OnInit {
99
+ ngOnInit() {
100
+ AOS.init();
101
+ }
102
+ }
103
+ ```
87
104
 
88
- - `flip` - 3D flip on Y-axis
89
- - `flip-x` - 3D flip on X-axis
90
- - `slide-rotate` - Slide + rotate 10°
91
- - `bounce-in` - Bouncy entrance (spring effect)
105
+ ```html
106
+ <!-- app.component.html -->
107
+ <div data-aos="fade-up">Animated</div>
108
+ ```
92
109
 
93
- ---
110
+ ### CDN (not tested — should work)
94
111
 
95
- ## ⚙️ Options
112
+ ```html
113
+ <script type="module">
114
+ import AOS from "https://esm.sh/rune-scroller/aos";
115
+ AOS.init();
116
+ </script>
96
117
 
97
- ```typescript
98
- interface RuneScrollerOptions {
99
- animation?: AnimationType // Animation name (default: 'fade-in')
100
- duration?: number // Duration in ms (default: 800)
101
- repeat?: boolean // Repeat on scroll (default: false)
102
- debug?: boolean // Show sentinel as visible line (default: false)
103
- offset?: number // Sentinel offset in px (default: 0, negative = earlier)
104
- onVisible?: (element: HTMLElement) => void // Callback when visible
105
- sentinelColor?: string // Debug sentinel color (e.g. '#ff6b6b')
106
- sentinelId?: string // Custom sentinel ID
107
- }
118
+ <div data-aos="fade-up">Works without any build step</div>
108
119
  ```
109
120
 
110
- ### Examples
121
+ ---
111
122
 
112
- ```svelte
113
- <!-- Basic -->
114
- <div use:runeScroller={{ animation: 'zoom-in' }}>Content</div>
123
+ ## ✨ Features
115
124
 
116
- <!-- Custom duration -->
117
- <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>Fast</div>
125
+ - **Framework agnostic** — Svelte, React, Vue, Angular, Vanilla JS, CDN
126
+ - **AOS drop-in** — Same `data-aos` attributes, same `init()` API
127
+ - **Zero dependencies** — Pure JS + native IntersectionObserver
128
+ - **~5.6KB gzipped** — Smaller than AOS (6.9KB)
129
+ - **37 animations** — Fade, Zoom, Flip, Slide, Bounce
130
+ - **Zero JS on scroll** — Browser handles detection natively
131
+ - **TypeScript support** — Full type definitions
132
+ - **SSR-ready** — SvelteKit, Next.js, Nuxt compatible
133
+ - **GPU-accelerated** — CSS transforms via `translate3d`
134
+ - **Accessible** — Respects `prefers-reduced-motion`
135
+ - **No wrapper divs** — Your layouts stay intact
118
136
 
119
- <!-- Repeat mode -->
120
- <div use:runeScroller={{ animation: 'bounce-in', repeat: true }}>Repeats</div>
137
+ ---
121
138
 
122
- <!-- Debug mode -->
123
- <div use:runeScroller={{ animation: 'fade-in', debug: true }}>Debug</div>
139
+ ### AOS vs rune-scroller
124
140
 
125
- <!-- Trigger earlier with negative offset -->
126
- <div use:runeScroller={{ animation: 'fade-in-up', offset: -200 }}>
127
- Triggers 200px before element bottom
128
- </div>
141
+ | | rune-scroller | AOS |
142
+ | ------------------------- | ---------------------------------------------- | ------------------------------------------ |
143
+ | **Bundle size (gzipped)** | **~5.6KB** JS+CSS | ~6.9KB JS+CSS |
144
+ | **Dependencies** | **0** | lodash.throttle, lodash.debounce |
145
+ | **Scroll detection** | **IntersectionObserver** (native, C++) | Scroll event + throttle (JS) |
146
+ | **Per-scroll cost** | **0** — browser handles it | Iterates ALL elements every 99ms |
147
+ | **Layout reads** | **1 per element** (init only) | `offsetParent` loop per element per scroll |
148
+ | **Resize handling** | **ResizeObserver** (native) | debounced scroll recalc |
149
+ | **100 animated elements** | **~0ms per scroll** | ~2-5ms per scroll (layout thrashing) |
150
+ | **Animations** | 30 | 28 |
151
+ | **Framework** | **Any** (Svelte, React, Vue, Angular, Vanilla) | Vanilla JS only |
129
152
 
130
- <!-- onVisible callback for analytics -->
131
- <div use:runeScroller={{
132
- animation: 'fade-in-up',
133
- onVisible: (el) => {
134
- window.gtag?.('event', 'section_viewed', { id: el.id });
135
- }
136
- }}>
137
- Tracked section
138
- </div>
139
- ```
153
+ The key difference: **AOS runs JavaScript on every scroll event** for every element. rune-scroller delegates detection to the browser's native IntersectionObserver — zero JS execution until an element actually enters the viewport.
140
154
 
141
155
  ---
142
156
 
143
- ## 🎯 How It Works
157
+ ## 🎨 Available Animations (30)
144
158
 
145
- **Sentinel-based triggering:**
159
+ ### Fade (10)
146
160
 
147
- 1. Invisible 1px sentinel created below your element
148
- 2. When sentinel enters viewport, animation triggers
149
- 3. Uses native IntersectionObserver for performance
150
- 4. Pure CSS animations (GPU-accelerated)
151
- 5. ResizeObserver auto-repositions sentinel
161
+ - `fade` Simple opacity fade
162
+ - `fade-up` / `fade-down` / `fade-left` / `fade-right` — Fade + translate
163
+ - `fade-up-right` / `fade-up-left` / `fade-down-right` / `fade-down-left` — Diagonal fades
152
164
 
153
- **Why sentinels?**
165
+ ### Zoom (10)
154
166
 
155
- - Accurate timing across all screen sizes
156
- - No complex offset calculations
157
- - Works with animated elements (transforms don't affect observer)
167
+ - `zoom-in` / `zoom-out` Scale in/out
168
+ - `zoom-in-up` / `zoom-in-down` / `zoom-in-left` / `zoom-in-right` — Zoom + translate
169
+ - `zoom-out-up` / `zoom-out-down` / `zoom-out-left` / `zoom-out-right` — Zoom out + translate
158
170
 
159
- ---
171
+ ### Slide (4)
160
172
 
161
- ## 🌐 SSR Compatibility
173
+ - `slide-up` / `slide-down` / `slide-left` / `slide-right` — Slide from off-screen
162
174
 
163
- Works seamlessly with SvelteKit:
175
+ ### Flip (4)
164
176
 
165
- ```svelte
166
- <!-- src/routes/+layout.svelte -->
167
- <script>
168
- import runeScroller from 'rune-scroller';
169
- let { children } = $props();
170
- </script>
177
+ - `flip-left` / `flip-right` — 3D flip on Y-axis
178
+ - `flip-up` / `flip-down` — 3D flip on X-axis
171
179
 
172
- {@render children()}
180
+ ### Special (2)
181
+
182
+ - `slide-rotate` — Slide + rotate
183
+ - `bounce-in` — Bouncy spring entrance
184
+
185
+ ### Customizable distance
186
+
187
+ All animations use the `--rs-distance` CSS variable (default: `100px`):
188
+
189
+ ```html
190
+ <div data-aos="fade-up" style="--rs-distance: 200px">Farther slide</div>
173
191
  ```
174
192
 
175
193
  ---
176
194
 
177
- ## Accessibility
195
+ ## ⚙️ Options
178
196
 
179
- Respects `prefers-reduced-motion`:
197
+ ### AOS Mode (data attributes)
198
+
199
+ | Attribute | Example | Description |
200
+ | ------------------- | --------------- | -------------------------- |
201
+ | `data-aos` | `"fade-up"` | Animation name |
202
+ | `data-aos-duration` | `"800"` | Duration in ms |
203
+ | `data-aos-delay` | `"200"` | Delay in ms |
204
+ | `data-aos-easing` | `"ease-in-out"` | CSS timing function |
205
+ | `data-aos-offset` | `"120"` | Trigger offset in px |
206
+ | `data-aos-once` | `"true"` | Animate only once |
207
+ | `data-aos-mirror` | `"true"` | Animate on scroll away too |
208
+
209
+ ### AOS init options
210
+
211
+ ```js
212
+ AOS.init({
213
+ offset: 120,
214
+ duration: 400,
215
+ delay: 0,
216
+ easing: "ease",
217
+ once: false,
218
+ mirror: false,
219
+ startEvent: "DOMContentLoaded",
220
+ });
221
+ ```
180
222
 
181
- ```css
182
- @media (prefers-reduced-motion: reduce) {
183
- .scroll-animate {
184
- animation: none !important;
185
- opacity: 1 !important;
186
- transform: none !important;
187
- }
223
+ ### Svelte Action options
224
+
225
+ ```typescript
226
+ interface RuneScrollerOptions {
227
+ animation?: AnimationType; // default: 'fade-up'
228
+ duration?: number; // default: 400
229
+ delay?: number; // default: 0
230
+ easing?: string; // default: 'ease'
231
+ repeat?: boolean; // default: false
232
+ debug?: boolean;
233
+ offset?: number; // negative = earlier trigger
234
+ onVisible?: (el: HTMLElement) => void;
235
+ sentinelColor?: string;
236
+ sentinelId?: string;
188
237
  }
189
238
  ```
190
239
 
191
240
  ---
192
241
 
242
+ ## 🎯 How It Works
243
+
244
+ 1. Invisible 1px sentinel appended as child of the animated element
245
+ 2. When sentinel enters viewport, animation triggers via IntersectionObserver
246
+ 3. Pure CSS transitions (GPU-accelerated via `translate3d`)
247
+ 4. ResizeObserver auto-repositions sentinel
248
+
249
+ **No wrapper divs** — the element itself becomes the positioning context. Your flex/grid layouts stay intact.
250
+
251
+ ---
252
+
253
+ ## ♿ Accessibility
254
+
255
+ Respects `prefers-reduced-motion` — animations are disabled automatically.
256
+
257
+ ---
258
+
193
259
  ## 📚 API Reference
194
260
 
195
261
  ```typescript
196
- // Default export
197
- import runeScroller from "rune-scroller"
262
+ // Framework agnostic (AOS mode)
263
+ import AOS from "rune-scroller/aos";
264
+ AOS.init();
265
+ AOS.refresh();
266
+ AOS.refreshHard();
267
+
268
+ // Svelte action (default)
269
+ import rs from "rune-scroller";
198
270
 
199
271
  // Named exports
200
272
  import {
201
- useIntersection, // Composable
202
- useIntersectionOnce, // Composable
203
- calculateRootMargin, // Utility
204
- } from "rune-scroller"
273
+ runeScroller,
274
+ useIntersection,
275
+ useIntersectionOnce,
276
+ calculateRootMargin,
277
+ ANIMATION_TYPES,
278
+ } from "rune-scroller";
205
279
 
206
280
  // Types
207
- import type { AnimationType, RuneScrollerOptions } from "rune-scroller"
281
+ import type { AnimationType, RuneScrollerOptions } from "rune-scroller";
208
282
  ```
209
283
 
210
284
  ---
@@ -215,16 +289,12 @@ import type { AnimationType, RuneScrollerOptions } from "rune-scroller"
215
289
 
216
290
  ```svelte
217
291
  <script>
218
- import runeScroller from 'rune-scroller';
292
+ import rs from 'rune-scroller';
219
293
  const items = ['Item 1', 'Item 2', 'Item 3'];
220
294
  </script>
221
295
 
222
296
  {#each items as item, i}
223
- <div use:runeScroller={{
224
- animation: 'fade-in-up',
225
- duration: 800,
226
- style: `--delay: ${i * 100}ms`
227
- }}>
297
+ <div use:rs={{ animation: 'fade-up', duration: 800, delay: i * 100 }}>
228
298
  {item}
229
299
  </div>
230
300
  {/each}
@@ -232,19 +302,36 @@ import type { AnimationType, RuneScrollerOptions } from "rune-scroller"
232
302
 
233
303
  ### Hero Section
234
304
 
235
- ```svelte
236
- <h1 use:runeScroller={{ animation: 'fade-in-down', duration: 1000 }}>Welcome</h1>
237
- <p use:runeScroller={{ animation: 'fade-in-up', duration: 1200 }}>Subtitle</p>
238
- <button use:runeScroller={{ animation: 'zoom-in', duration: 800 }}>Get Started</button>
305
+ ```html
306
+ <h1 data-aos="fade-down" data-aos-duration="1000">Welcome</h1>
307
+ <p data-aos="fade-up" data-aos-duration="1200">Subtitle</p>
308
+ <button data-aos="zoom-in" data-aos-duration="800">Get Started</button>
239
309
  ```
240
310
 
241
311
  ---
242
312
 
313
+ ## 🔄 Replacing AOS
314
+
315
+ ```bash
316
+ npm uninstall aos
317
+ npm install rune-scroller
318
+ ```
319
+
320
+ ```diff
321
+ - import AOS from 'aos';
322
+ - import 'aos/dist/aos.css';
323
+ + import AOS from 'rune-scroller/aos';
324
+ ```
325
+
326
+ Everything else stays the same. Same attributes, same options.
327
+
328
+ ---
329
+
243
330
  ## 🔗 Links
244
331
 
245
332
  - **npm**: [rune-scroller](https://www.npmjs.com/package/rune-scroller)
246
333
  - **GitHub**: [lelabdev/rune-scroller](https://github.com/lelabdev/rune-scroller)
247
- - **Changelog**: [CHANGELOG.md](https://github.com/lelabdev/rune-scroller/blob/main/lib/CHANGELOG.md)
334
+ - **Changelog**: [CHANGELOG.md](./CHANGELOG.md)
248
335
 
249
336
  ---
250
337
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rune-scroller",
3
- "version": "2.2.2",
4
- "description": "Lightweight, high-performance scroll animations for Svelte 5. 14KB gzipped, zero dependencies.",
3
+ "version": "3.0.0",
4
+ "description": "Lightweight scroll animations for Svelte 5. Drop-in AOS replacement. 30 animations, zero dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "license": "MIT",
@@ -29,6 +29,9 @@
29
29
  "svelte": "./dist/index.js",
30
30
  "default": "./dist/index.js"
31
31
  },
32
+ "./aos": {
33
+ "default": "./dist/aos.js"
34
+ },
32
35
  "./animations.css": "./dist/animations.css"
33
36
  },
34
37
  "files": [
@@ -59,7 +62,7 @@
59
62
  "@eslint/compat": "^1.4.0",
60
63
  "@eslint/js": "^9.36.0",
61
64
  "@sveltejs/kit": "^2.43.2",
62
- "@sveltejs/package": "^2.5.4",
65
+ "@sveltejs/package": "^2.5.7",
63
66
  "@sveltejs/vite-plugin-svelte": "^6.2.0",
64
67
  "@types/node": "^22",
65
68
  "eslint": "^9.36.0",
@@ -1,83 +0,0 @@
1
- /**
2
- * Reusable scroll animation styles - Optimized with CSS custom properties
3
- * Reduces bundle size while maintaining all 14 animations
4
- */
5
-
6
- /* Base animation container with optimized transitions */
7
- .scroll-animate,
8
- .animated-element {
9
- opacity: 0;
10
- transition: opacity var(--duration, 2500ms) linear var(--delay, 0ms),
11
- transform var(--duration, 2500ms) cubic-bezier(0.34, 1.56, 0.64, 1) var(--delay, 0ms);
12
- transform: perspective(1000px) translate(var(--tx, 0), var(--ty, 0)) scale(var(--scale, 1)) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) rotate(var(--rotate, 0deg));
13
- }
14
-
15
- .scroll-animate.is-visible,
16
- .animated-element.is-visible {
17
- opacity: 1 !important;
18
- will-change: transform, opacity;
19
- transform: perspective(1000px) translate(var(--tx, 0), var(--ty, 0)) scale(var(--scale, 1)) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) rotate(var(--rotate, 0deg));
20
- }
21
-
22
- /* Initial state - transform values before animation */
23
- [data-animation='fade-in'] { --tx: 0; --ty: 0; }
24
- [data-animation='fade-in-up'] { --ty: var(--translate-distance, 300px); }
25
- [data-animation='fade-in-down'] { --ty: calc(-1 * var(--translate-distance, 300px)); }
26
- [data-animation='fade-in-left'] { --tx: calc(-1 * var(--translate-distance, 300px)); }
27
- [data-animation='fade-in-right'] { --tx: var(--translate-distance, 300px); }
28
-
29
- [data-animation='zoom-in'] { --scale: 0.3; }
30
- [data-animation='zoom-out'] { --scale: 2; }
31
- [data-animation='zoom-in-up'] { --scale: 0.5; --ty: var(--translate-distance, 300px); }
32
- [data-animation='zoom-in-left'] { --scale: 0.5; --tx: calc(-1 * var(--translate-distance, 300px)); }
33
- [data-animation='zoom-in-right'] { --scale: 0.5; --tx: var(--translate-distance, 300px); }
34
-
35
- [data-animation='flip'] { --ry: 90deg; }
36
- [data-animation='flip-x'] { --rx: 90deg; }
37
-
38
- [data-animation='slide-rotate'] { --tx: calc(-1 * var(--translate-distance, 300px)); --rotate: -45deg; }
39
- [data-animation='bounce-in'] { --scale: 0; }
40
-
41
- /* Visible state - reset transform values to final position */
42
- [data-animation='fade-in-up'].is-visible { --ty: 0; }
43
- [data-animation='fade-in-down'].is-visible { --ty: 0; }
44
- [data-animation='fade-in-left'].is-visible { --tx: 0; }
45
- [data-animation='fade-in-right'].is-visible { --tx: 0; }
46
-
47
- [data-animation='zoom-in'].is-visible { --scale: 1; }
48
- [data-animation='zoom-out'].is-visible { --scale: 1; }
49
- [data-animation='zoom-in-up'].is-visible { --scale: 1; --ty: 0; }
50
- [data-animation='zoom-in-left'].is-visible { --scale: 1; --tx: 0; }
51
- [data-animation='zoom-in-right'].is-visible { --scale: 1; --tx: 0; }
52
-
53
- [data-animation='flip'].is-visible { --ry: 0deg; }
54
- [data-animation='flip-x'].is-visible { --rx: 0deg; }
55
-
56
- [data-animation='slide-rotate'].is-visible { --tx: 0; --rotate: 0deg; }
57
-
58
- @keyframes bounce {
59
- 0% { transform: scale(0); }
60
- 50% { transform: scale(1.1); }
61
- 100% { transform: scale(1); }
62
- }
63
-
64
- /* Bounce animation - special case with keyframes */
65
- [data-animation='bounce-in'].is-visible {
66
- animation: bounce var(--duration, 1500ms) cubic-bezier(0.68, -0.55, 0.265, 1.55);
67
- animation-delay: var(--delay, 0ms);
68
- }
69
-
70
- /* Accessibility: Respect user's motion preferences */
71
- @media (prefers-reduced-motion: reduce) {
72
- .scroll-animate,
73
- .animated-element {
74
- transition: none;
75
- animation: none !important;
76
- }
77
-
78
- .scroll-animate.is-visible,
79
- .animated-element.is-visible {
80
- opacity: 1;
81
- transform: none !important;
82
- }
83
- }
@@ -1,18 +0,0 @@
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
- * Useful for programmatic access and validation
16
- * @type {readonly string[]}
17
- */
18
- export const ANIMATION_TYPES: readonly string[];
@@ -1,38 +0,0 @@
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
- * Useful for programmatic access and validation
9
- * @type {readonly string[]}
10
- */
11
- export const ANIMATION_TYPES = [
12
- 'fade-in',
13
- 'fade-in-up',
14
- 'fade-in-down',
15
- 'fade-in-left',
16
- 'fade-in-right',
17
- 'zoom-in',
18
- 'zoom-out',
19
- 'zoom-in-up',
20
- 'zoom-in-left',
21
- 'zoom-in-right',
22
- 'flip',
23
- 'flip-x',
24
- 'slide-rotate',
25
- 'bounce-in'
26
- ];
27
-
28
- /**
29
- * Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
30
- *
31
- * @param {number} [offset] - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
32
- * @param {string} [rootMargin] - Custom rootMargin string (takes precedence over offset)
33
- * @returns {string} rootMargin string for IntersectionObserver
34
- */
35
- export function calculateRootMargin(offset, rootMargin) {
36
- return rootMargin ??
37
- (offset !== undefined ? `-${100 - offset}% 0px -${offset}% 0px` : '-10% 0px -10% 0px');
38
- }
@@ -1,30 +0,0 @@
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;
package/dist/dom-utils.js DELETED
@@ -1,112 +0,0 @@
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(element, debug = false, offset = 0, sentinelColor = '#00e0ff', debugLabel = '', sentinelId) {
45
- const sentinel = document.createElement('div');
46
- // Use offsetHeight instead of getBoundingClientRect for accurate dimensions
47
- // getBoundingClientRect returns transformed dimensions (affected by scale, etc)
48
- // offsetHeight returns actual element height independent of CSS transforms
49
- const elementHeight = element.offsetHeight;
50
- const sentinelTop = elementHeight + offset;
51
-
52
- // Generate auto-ID if not provided
53
- if (!sentinelId) {
54
- sentinelCounter++;
55
- sentinelId = `sentinel-${sentinelCounter}`;
56
- }
57
-
58
- // Always set to data-sentinel-id attribute
59
- sentinel.setAttribute('data-sentinel-id', sentinelId);
60
-
61
- if (debug) {
62
- sentinel.style.cssText =
63
- `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`;
64
- sentinel.setAttribute('data-sentinel-debug', 'true');
65
- // Show ID in debug mode (or debugLabel if provided)
66
- if (debugLabel) {
67
- sentinel.textContent = debugLabel;
68
- } else {
69
- sentinel.textContent = sentinelId;
70
- }
71
- } else {
72
- sentinel.style.cssText =
73
- `position:absolute;top:${sentinelTop}px;left:0;width:100%;height:1px;visibility:hidden;margin:0;padding:0;box-sizing:border-box;pointer-events:none`;
74
- }
75
-
76
- return { element: sentinel, id: sentinelId };
77
- }
78
-
79
- /**
80
- * Check if CSS animations are loaded and warn if not (dev only)
81
- * Uses cache to avoid expensive getComputedStyle() on every element creation
82
- * @returns {boolean} True if CSS appears to be loaded
83
- */
84
- export function checkAndWarnIfCSSNotLoaded() {
85
- if (typeof document === 'undefined') return false;
86
- if (process.env.NODE_ENV === 'production') return true;
87
-
88
- // Return cached result if already checked (avoids expensive reflows)
89
- if (cssCheckResult !== null) return cssCheckResult;
90
-
91
- // Try to detect if animations.css is loaded by checking for animation classes
92
- const test = document.createElement('div');
93
- test.className = 'scroll-animate is-visible';
94
- test.style.position = 'absolute';
95
- test.style.opacity = '0';
96
- document.body.appendChild(test);
97
- const computed = getComputedStyle(test);
98
- const hasAnimation = computed.animation !== 'none' && computed.animation !== '';
99
- test.remove();
100
-
101
- if (!hasAnimation) {
102
- console.warn(
103
- '[rune-scroller] CSS animations not found. Make sure to import the animations:\n' +
104
- ' import "rune-scroller/animations.css";\n' +
105
- 'Documentation: https://github.com/lelabdev/rune-scroller#installation'
106
- );
107
- }
108
-
109
- // Cache the result for future calls
110
- cssCheckResult = hasAnimation;
111
- return hasAnimation;
112
- }
package/dist/index.d.ts DELETED
@@ -1,5 +0,0 @@
1
- export default runeScroller;
2
- export { runeScroller };
3
- export { calculateRootMargin } from "./animations.js";
4
- import { runeScroller } from './runeScroller.js';
5
- export { useIntersection, useIntersectionOnce } from "./useIntersection.svelte.js";
package/dist/index.js DELETED
@@ -1,23 +0,0 @@
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
- // Import CSS animations automatically
10
- import './animations.css';
11
-
12
- // Main action (default export - recommended)
13
- import { runeScroller } from './runeScroller.js';
14
- export default runeScroller;
15
- export { runeScroller };
16
-
17
-
18
-
19
- // Composables
20
- export { useIntersection, useIntersectionOnce } from './useIntersection.svelte.js';
21
-
22
- // Utilities
23
- export { calculateRootMargin } from './animations.js';
@@ -1,21 +0,0 @@
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;
@@ -1,31 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
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
- };
@@ -1,166 +0,0 @@
1
- import { setCSSVariables, setupAnimationElement, createSentinel, checkAndWarnIfCSSNotLoaded } from './dom-utils.js';
2
- import { createManagedObserver, disconnectObserver } from './observer-utils.js';
3
- import { ANIMATION_TYPES } from './animations.js';
4
-
5
- /**
6
- * @param {HTMLElement} element
7
- * @param {import('./types.js').RuneScrollerOptions} [options]
8
- * @returns {{ update: (newOptions?: import('./types.js').RuneScrollerOptions) => void, destroy: () => void }}
9
- */
10
- export function runeScroller(element, options) {
11
- // SSR Guard: Return no-op action when running on server
12
- if (typeof window === 'undefined') {
13
- return {
14
- update: () => {},
15
- destroy: () => {}
16
- };
17
- }
18
-
19
- // Warn if CSS is not loaded (first time only)
20
- if (typeof document !== 'undefined') {
21
- checkAndWarnIfCSSNotLoaded();
22
- }
23
-
24
- // Validate animation type
25
- let animation = options?.animation ?? 'fade-in';
26
- if (animation && !ANIMATION_TYPES.includes(animation)) {
27
- if (process.env.NODE_ENV !== 'production') {
28
- console.warn(
29
- `[rune-scroller] Invalid animation "${animation}". Using "fade-in" instead. ` +
30
- `Valid options: ${ANIMATION_TYPES.join(', ')}`
31
- );
32
- }
33
- animation = 'fade-in';
34
- }
35
-
36
- // Initialize opacity: 0 BEFORE adding .scroll-animate class
37
- // This ensures the transition applies correctly when .is-visible is added later
38
- element.style.opacity = '0';
39
-
40
- // Setup animation classes and CSS variables
41
- if (animation) {
42
- setupAnimationElement(element, animation);
43
- }
44
-
45
- // Set CSS variables for duration
46
- if (options?.duration !== undefined) {
47
- setCSSVariables(element, options.duration);
48
- }
49
-
50
- // Force reflow to ensure transitions are ready
51
- void element.offsetHeight;
52
-
53
- // Create a wrapper div around the element to position the sentinel
54
- const wrapper = document.createElement('div');
55
- wrapper.style.cssText = 'position:relative;display:block;width:100%;margin:0;padding:0;box-sizing:border-box';
56
- element.insertAdjacentElement('beforebegin', wrapper);
57
- wrapper.appendChild(element);
58
-
59
- // Create the invisible sentinel (or visible if debug=true)
60
- // Positioned absolutely relative to the wrapper
61
- const sentinelResult = createSentinel(
62
- element,
63
- options?.debug,
64
- options?.offset,
65
- options?.sentinelColor,
66
- options?.debugLabel,
67
- options?.sentinelId
68
- );
69
- const sentinel = sentinelResult.element;
70
- const sentinelId = sentinelResult.id;
71
-
72
- // Add sentinel ID to element (either provided or auto-generated)
73
- element.setAttribute('data-sentinel-id', sentinelId);
74
-
75
- wrapper.appendChild(sentinel);
76
-
77
- // Observe the sentinel with cleanup tracking
78
- const state = { isConnected: true };
79
- let currentSentinel = sentinel;
80
- let resizeObserver;
81
- let intersectionObserver;
82
-
83
- const { observer } = createManagedObserver(
84
- sentinel,
85
- (entries) => {
86
- const isIntersecting = entries[0].isIntersecting;
87
- if (isIntersecting) {
88
- // Add the is-visible class to trigger animation
89
- element.classList.add('is-visible');
90
- // Call onVisible callback if provided
91
- options?.onVisible?.(element);
92
- // Disconnect if not in repeat mode
93
- if (!options?.repeat) {
94
- disconnectObserver(intersectionObserver, state);
95
- }
96
- } else if (options?.repeat) {
97
- // In repeat mode, remove the class when the sentinel exits
98
- element.classList.remove('is-visible');
99
- }
100
- },
101
- { threshold: 0 }
102
- );
103
-
104
- intersectionObserver = observer;
105
-
106
- // Function to recreate sentinel when element is resized
107
- const recreateSentinel = () => {
108
- const newSentinelResult = createSentinel(
109
- element,
110
- options?.debug,
111
- options?.offset,
112
- options?.sentinelColor,
113
- options?.debugLabel,
114
- sentinelId
115
- );
116
- const newSentinel = newSentinelResult.element;
117
- currentSentinel.replaceWith(newSentinel);
118
- currentSentinel = newSentinel;
119
- // Update observer to watch the new sentinel
120
- intersectionObserver.disconnect();
121
- intersectionObserver.observe(newSentinel);
122
- };
123
-
124
- // Setup ResizeObserver to handle element resizing
125
- if (typeof ResizeObserver !== 'undefined') {
126
- resizeObserver = new ResizeObserver(() => {
127
- recreateSentinel();
128
- });
129
- resizeObserver.observe(element);
130
- }
131
-
132
- return {
133
- update(newOptions) {
134
- if (newOptions?.animation) {
135
- element.setAttribute('data-animation', newOptions.animation);
136
- }
137
- if (newOptions?.duration) {
138
- setCSSVariables(element, newOptions.duration);
139
- }
140
- // Update repeat option
141
- if (newOptions?.repeat !== undefined && newOptions.repeat !== options?.repeat) {
142
- options = { ...options, repeat: newOptions.repeat };
143
- }
144
- // Update offset and debug if changed
145
- if ((newOptions?.offset !== undefined && newOptions.offset !== options?.offset) ||
146
- (newOptions?.debug !== undefined && newOptions.debug !== options?.debug)) {
147
- options = { ...options, ...newOptions };
148
- recreateSentinel();
149
- }
150
- },
151
- destroy() {
152
- disconnectObserver(intersectionObserver, state);
153
- // Cleanup ResizeObserver
154
- if (resizeObserver) {
155
- resizeObserver.disconnect();
156
- }
157
- currentSentinel.remove();
158
- // Unwrap element (move it out of wrapper)
159
- const parent = wrapper.parentElement;
160
- if (parent) {
161
- wrapper.insertAdjacentElement('beforebegin', element);
162
- }
163
- wrapper.remove();
164
- }
165
- };
166
- }
package/dist/types.d.ts DELETED
@@ -1,78 +0,0 @@
1
- /**
2
- * Animation type names available in Rune Scroller
3
- */
4
- export type AnimationType = "fade-in" | "fade-in-up" | "fade-in-down" | "fade-in-left" | "fade-in-right" | "zoom-in" | "zoom-out" | "zoom-in-up" | "zoom-in-left" | "zoom-in-right" | "flip" | "flip-x" | "slide-rotate" | "bounce-in";
5
- /**
6
- * Options for the runeScroller action
7
- * Sentinel-based scroll animation triggering
8
- */
9
- export type RuneScrollerOptions = {
10
- /**
11
- * - Animation type to apply
12
- */
13
- animation?: AnimationType | undefined;
14
- /**
15
- * - Animation duration in milliseconds
16
- */
17
- duration?: number | undefined;
18
- /**
19
- * - Repeat animation on every scroll
20
- */
21
- repeat?: boolean | undefined;
22
- /**
23
- * - Show sentinel as visible line for debugging
24
- */
25
- debug?: boolean | undefined;
26
- /**
27
- * - Sentinel color for debug mode (hex or CSS color)
28
- */
29
- sentinelColor?: string | undefined;
30
- /**
31
- * - Unique identifier for sentinel (auto-generated if not provided)
32
- */
33
- sentinelId?: string | undefined;
34
- /**
35
- * - Debug label to show on sentinel (e.g., animation name)
36
- */
37
- debugLabel?: string | undefined;
38
- /**
39
- * - Offset of sentinel in pixels (negative = above element)
40
- */
41
- offset?: number | undefined;
42
- /**
43
- * - Callback fired when animation becomes visible
44
- */
45
- onVisible?: ((element: HTMLElement) => void) | undefined;
46
- };
47
- /**
48
- * Configuration options for IntersectionObserver
49
- * Used by useIntersection and useIntersectionOnce composables
50
- */
51
- export type IntersectionOptions = {
52
- /**
53
- * - IntersectionObserver threshold
54
- */
55
- threshold?: number | number[] | undefined;
56
- /**
57
- * - Custom margin around root element
58
- */
59
- rootMargin?: string | undefined;
60
- /**
61
- * - Root element for intersection observation
62
- */
63
- root?: Element | null | undefined;
64
- };
65
- /**
66
- * Return type for useIntersection and useIntersectionOnce composables
67
- * Provides reactive element reference and visibility state
68
- */
69
- export type UseIntersectionReturn = {
70
- /**
71
- * - Reference to the DOM element being observed
72
- */
73
- element: HTMLElement | null;
74
- /**
75
- * - Whether the element is currently visible in viewport
76
- */
77
- isVisible: boolean;
78
- };
package/dist/types.js DELETED
@@ -1,48 +0,0 @@
1
- /**
2
- * Centralized type definitions for Rune Scroller library
3
- * All types are defined here for consistency and ease of maintenance
4
- */
5
-
6
- /**
7
- * Animation type names available in Rune Scroller
8
- * @typedef {'fade-in' | 'fade-in-up' | 'fade-in-down' | 'fade-in-left' | 'fade-in-right' | 'zoom-in' | 'zoom-out' | 'zoom-in-up' | 'zoom-in-left' | 'zoom-in-right' | 'flip' | 'flip-x' | 'slide-rotate' | 'bounce-in'} AnimationType
9
- */
10
-
11
- /**
12
- * Options for the runeScroller action
13
- * Sentinel-based scroll animation triggering
14
- *
15
- * @typedef {Object} RuneScrollerOptions
16
- * @property {AnimationType} [animation='fade-in'] - Animation type to apply
17
- * @property {number} [duration=2500] - Animation duration in milliseconds
18
- * @property {boolean} [repeat=false] - Repeat animation on every scroll
19
- * @property {boolean} [debug=false] - Show sentinel as visible line for debugging
20
- * @property {string} [sentinelColor='#00e0ff'] - Sentinel color for debug mode (hex or CSS color)
21
- * @property {string} [sentinelId] - Unique identifier for sentinel (auto-generated if not provided)
22
- * @property {string} [debugLabel] - Debug label to show on sentinel (e.g., animation name)
23
- * @property {number} [offset=0] - Offset of sentinel in pixels (negative = above element)
24
- * @property {(element: HTMLElement) => void} [onVisible] - Callback fired when animation becomes visible
25
- */
26
-
27
-
28
-
29
- /**
30
- * Configuration options for IntersectionObserver
31
- * Used by useIntersection and useIntersectionOnce composables
32
- *
33
- * @typedef {Object} IntersectionOptions
34
- * @property {number | number[]} [threshold] - IntersectionObserver threshold
35
- * @property {string} [rootMargin] - Custom margin around root element
36
- * @property {Element | null} [root] - Root element for intersection observation
37
- */
38
-
39
- /**
40
- * Return type for useIntersection and useIntersectionOnce composables
41
- * Provides reactive element reference and visibility state
42
- *
43
- * @typedef {Object} UseIntersectionReturn
44
- * @property {HTMLElement | null} element - Reference to the DOM element being observed
45
- * @property {boolean} isVisible - Whether the element is currently visible in viewport
46
- */
47
-
48
- export {};
@@ -1,11 +0,0 @@
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;
@@ -1,90 +0,0 @@
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(options = {}, onIntersect = undefined, once = false) {
13
- const { threshold = 0.5, rootMargin = '-10% 0px -10% 0px', root = null } = options;
14
-
15
- let element = $state(null);
16
- let isVisible = $state(false);
17
- let hasTriggeredOnce = false;
18
- /** @type {IntersectionObserver | null} */
19
- let observer = null;
20
-
21
- $effect(() => {
22
- if (!element) return;
23
-
24
- observer = new IntersectionObserver(
25
- (entries) => {
26
- entries.forEach((entry) => {
27
- // For once-only behavior, check if already triggered
28
- if (once && hasTriggeredOnce) return;
29
-
30
- isVisible = entry.isIntersecting;
31
- if (onIntersect) {
32
- onIntersect(entry, entry.isIntersecting);
33
- }
34
-
35
- // Unobserve after first trigger if once=true
36
- if (once && entry.isIntersecting) {
37
- hasTriggeredOnce = true;
38
- observer?.unobserve(entry.target);
39
- }
40
- });
41
- },
42
- {
43
- threshold,
44
- rootMargin,
45
- root
46
- }
47
- );
48
-
49
- observer.observe(element);
50
-
51
- return () => {
52
- observer?.disconnect();
53
- };
54
- });
55
-
56
- return {
57
- get element() {
58
- return element;
59
- },
60
- set element(value) {
61
- element = value;
62
- },
63
- get isVisible() {
64
- return isVisible;
65
- }
66
- };
67
- }
68
-
69
- /**
70
- * @param {import('./types.js').IntersectionOptions} [options={}]
71
- * @param {(isVisible: boolean) => void} [onVisible]
72
- * @returns {import('./types.js').UseIntersectionReturn}
73
- */
74
- export function useIntersection(options = {}, onVisible) {
75
- return createIntersectionObserver(
76
- options,
77
- (_entry, isVisible) => {
78
- onVisible?.(isVisible);
79
- },
80
- false
81
- );
82
- }
83
-
84
- /**
85
- * @param {import('./types.js').IntersectionOptions} [options={}]
86
- * @returns {import('./types.js').UseIntersectionReturn}
87
- */
88
- export function useIntersectionOnce(options = {}) {
89
- return createIntersectionObserver(options, () => {}, true);
90
- }