svelte-intersection-observer 2.0.0 → 2.2.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
@@ -1,20 +1,31 @@
1
1
  # svelte-intersection-observer
2
2
 
3
+ <!-- HIDE_START -->
3
4
  [![NPM][npm]][npm-url]
5
+ <!-- HIDE_END -->
4
6
 
5
- > Detect if an element is in the viewport using the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
7
+ ## About
6
8
 
7
- Use it to lazy-load images, trigger scroll animations, implement infinite scroll, autoplay video when visible, track ad or analytics impressions, or detect when a user has scrolled to the end of a list.
9
+ `svelte-intersection-observer` is a zero-dependency Svelte library built on the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) that detects when an element enters or exits the viewport, without expensive scroll listeners. Use it for lazy-loading, scroll animations, infinite scroll, autoplaying video, impression tracking, and more (see [Use Cases](#use-cases)).
8
10
 
9
- This library is zero-dependency and offers three ways to observe elements: the `IntersectionObserver` component for a single element, the `MultipleIntersectionObserver` component for observing many elements with one shared observer (better performance than one observer per element), and the `intersect` action for observing an element directly with `use:`.
11
+ It offers six interchangeable primitives, all backed by the same shared observer logic.
10
12
 
11
- Try it in the [Svelte REPL](https://svelte.dev/repl/8cd2327a580c4f429c71f7df999bd51d).
13
+ | Primitive | Export | Use it when... |
14
+ | :-------- | :----- | :--------------------- |
15
+ | [Component](#intersectionobserver) | `IntersectionObserver` | you want a component with a bound `intersecting` prop |
16
+ | [Pooled component](#multipleintersectionobserver) | `MultipleIntersectionObserver` | you're observing many elements and want one shared observer |
17
+ | [Action](#intersect) | `intersect` | you want `use:` on a plain element, no extra markup |
18
+ | [Attachment](#intersectattachment) | `intersectAttachment` | same as the action, composed via `{@attach}` (Svelte 5.29+) |
19
+ | [Composable](#createintersectionobserver) | `createIntersectionObserver` | you want reactive state from `<script>`, no directive |
20
+ | [Factory](#createintersectiongroup) | `createIntersectionGroup` | you're rendering a list with `{@attach}` and want one shared observer |
12
21
 
13
- ## Compatibility
22
+ See [Library](#library) for the full docs on each. Try it in the [Svelte REPL](https://svelte.dev/repl/8cd2327a580c4f429c71f7df999bd51d).
23
+
24
+ ### Compatibility
14
25
 
15
26
  | Package version | Svelte version | Notes |
16
27
  | :--------------- | :----------------- | :----------------------------------------- |
17
- | 1.x | 3, 4, 5 (non-runes) | Uses `export let`, slots, and `on:` events |
28
+ | [1.x](https://github.com/metonym/svelte-intersection-observer/tree/v1.2.x) | 3, 4, 5 (non-runes) | Uses `export let`, slots, and `on:` events |
18
29
  | 2.x | 5 (runes mode only) | Uses `$props()`, snippets, and callback props |
19
30
 
20
31
  <!-- TOC -->
@@ -36,19 +47,21 @@ yarn add svelte-intersection-observer
36
47
 
37
48
  ```
38
49
 
39
- ## Usage
50
+ ## Library
51
+
52
+ Every primitive shares the same core options (`root`, `rootMargin`, `threshold`, `once`, `skip`) and the same `onobserve`/`onintersect` callbacks. Only how you plug it into your markup differs. See [Use Cases](#use-cases) for realistic scenarios built from these.
40
53
 
41
- ### Basic
54
+ ### `IntersectionObserver`
42
55
 
43
56
  Use the [`bind:this`](https://svelte.dev/docs#bind_element) directive to pass an element reference to the `IntersectionObserver` component.
44
57
 
45
- Then, simply bind to the reactive `intersecting` prop to determine if the element intersects the viewport.
58
+ Then bind to the reactive `intersecting` prop to check whether the element intersects the viewport.
46
59
 
47
60
  ```svelte
48
- <script>
61
+ <script lang="ts">
49
62
  import IntersectionObserver from "svelte-intersection-observer";
50
63
 
51
- let element = $state();
64
+ let element: HTMLElement | undefined = $state();
52
65
  let intersecting = $state(false);
53
66
  </script>
54
67
 
@@ -61,42 +74,15 @@ Then, simply bind to the reactive `intersecting` prop to determine if the elemen
61
74
  </IntersectionObserver>
62
75
  ```
63
76
 
64
- ### `children` snippet
65
-
66
- An alternative to binding to the `intersecting` prop is to use the `children` snippet, which receives `intersecting`, `entry`, and `observer`.
67
-
68
- In the following example, the "Hello world" element fades in when its containing element intersects the viewport.
69
-
70
- ```svelte
71
- <script>
72
- import IntersectionObserver from "svelte-intersection-observer";
73
- import { fade } from "svelte/transition";
74
-
75
- let node = $state();
76
- </script>
77
-
78
- <header></header>
79
-
80
- <IntersectionObserver element={node}>
81
- {#snippet children({ intersecting })}
82
- <div bind:this={node}>
83
- {#if intersecting}
84
- <div transition:fade={{ delay: 200 }}>Hello world</div>
85
- {/if}
86
- </div>
87
- {/snippet}
88
- </IntersectionObserver>
89
- ```
90
-
91
- ### Once
77
+ #### `once`
92
78
 
93
- Set `once` to `true` for the intersection event to occur only once. The `element` will be unobserved after the first intersection event occurs.
79
+ Set `once` to `true` to unobserve the element after its first intersection event, useful for a one-time reveal animation, a single lazy-load, or a single analytics impression.
94
80
 
95
81
  ```svelte
96
- <script>
82
+ <script lang="ts">
97
83
  import IntersectionObserver from "svelte-intersection-observer";
98
84
 
99
- let elementOnce = $state();
85
+ let elementOnce: HTMLElement | undefined = $state();
100
86
  let intersectOnce = $state(false);
101
87
  </script>
102
88
 
@@ -113,167 +99,71 @@ Set `once` to `true` for the intersection event to occur only once. The `element
113
99
  </IntersectionObserver>
114
100
  ```
115
101
 
116
- ### Pausing with `skip`
117
-
118
- Set `skip` to `true` to unobserve without disconnecting the underlying observer or losing `entry`/`intersecting` state. This is useful for pausing tracking on an off-screen carousel panel or a closed modal. Set `skip` back to `false` to resume; unlike `once`, this can be toggled back and forth. `MultipleIntersectionObserver` and the `intersect` action support the same `skip` option.
119
-
120
- ```svelte no-eval
121
- <script>
122
- import IntersectionObserver from "svelte-intersection-observer";
123
-
124
- let elementSkip = $state();
125
- let paused = $state(false);
126
- </script>
127
-
128
- <button onclick={() => (paused = !paused)}>
129
- {paused ? "Resume" : "Pause"}
130
- </button>
131
-
132
- <IntersectionObserver element={elementSkip} skip={paused}>
133
- {#snippet children({ intersecting })}
134
- <div bind:this={elementSkip}>{intersecting ? "In view" : "Not in view"}</div>
135
- {/snippet}
136
- </IntersectionObserver>
137
- ```
138
-
139
- ### `onobserve` callback prop
140
-
141
- `onobserve` is called when the element is first observed and also whenever an intersection event occurs.
142
-
143
- ```svelte no-eval
144
- <IntersectionObserver
145
- {element}
146
- onobserve={(entry) => {
147
- console.log(entry); // IntersectionObserverEntry
148
- console.log(entry.isIntersecting); // true | false
149
- }}
150
- >
151
- <div bind:this={element}>Hello world</div>
152
- </IntersectionObserver>
153
- ```
154
-
155
- ### `onintersect` callback prop
156
-
157
- As an alternative to binding the `intersecting` prop, you can pass an `onintersect` callback that is called if the observed element is intersecting the viewport.
158
-
159
- **Note**: Compared to `onobserve`, `onintersect` is called only when the element is _intersecting the viewport_. In other words, `entry.isIntersecting` will only be `true`.
160
-
161
- ```svelte no-eval
162
- <IntersectionObserver
163
- {element}
164
- onintersect={(entry) => {
165
- console.log(entry); // IntersectionObserverEntry
166
- console.log(entry.isIntersecting); // true
167
- }}
168
- >
169
- <div bind:this={element}>Hello world</div>
170
- </IntersectionObserver>
171
- ```
172
-
173
- ### Detecting scroll to end
174
-
175
- To detect when a user has scrolled to the end of a scrollable container, place a sentinel element after the content and set `root` to the container. `intersecting` becomes `true` once the sentinel scrolls into view.
176
-
177
- **Note**: `root` must be the scrollable element itself (i.e. it has its own `overflow`/fixed `height`), not just an ancestor of one. If `root` merely sits inside a scrollable ancestor, the sentinel scrolls along with `root` and never changes position relative to it, so it reports as permanently intersecting.
178
-
179
- ```svelte
180
- <script>
181
- import IntersectionObserver from "svelte-intersection-observer";
182
-
183
- let container = $state();
184
- let sentinel = $state();
185
- let reachedEnd = $state(false);
186
- </script>
187
-
188
- <header class:intersecting={reachedEnd}>
189
- {reachedEnd ? "You've reached the end" : "Keep scrolling..."}
190
- </header>
191
-
192
- <div bind:this={container} style="height: 200px; overflow-y: auto;">
193
- {#each Array.from({ length: 20 }) as _, i}
194
- <p>Paragraph {i + 1}</p>
195
- {/each}
196
-
197
- <IntersectionObserver
198
- element={sentinel}
199
- root={container}
200
- bind:intersecting={reachedEnd}
201
- >
202
- <div bind:this={sentinel} style="height: 1px;"></div>
203
- </IntersectionObserver>
204
- </div>
205
- ```
102
+ #### `children` snippet
206
103
 
207
- ### Scrolling to a conditionally revealed element
104
+ An alternative to binding to the `intersecting` prop is to use the `children` snippet, which receives `intersecting`, `entry`, and `observer`.
208
105
 
209
- Keep the `bind:this` element outside the `{#if intersecting}` block, and only gate the animated content inside it. The bound element then stays in the DOM, so you can call `scrollIntoView()` on it whenever you want, whether or not its reveal animation has played yet.
106
+ In this example, "Hello world" fades in when its containing element intersects the viewport.
210
107
 
211
108
  ```svelte
212
- <script>
109
+ <script lang="ts">
213
110
  import IntersectionObserver from "svelte-intersection-observer";
214
- import { fly } from "svelte/transition";
111
+ import { fade } from "svelte/transition";
215
112
 
216
- let revealNode = $state();
217
- let revealIntersecting = $state(false);
113
+ let node: HTMLElement | undefined = $state();
218
114
  </script>
219
115
 
220
- <header class:intersecting={revealIntersecting}>
221
- <button
222
- onclick={() => {
223
- revealNode.scrollIntoView({ behavior: "smooth", block: "nearest" });
224
- }}
225
- >
226
- Scroll to section
227
- </button>
228
- </header>
116
+ <header></header>
229
117
 
230
- <IntersectionObserver element={revealNode} once bind:intersecting={revealIntersecting}>
231
- <div bind:this={revealNode}>
232
- {#if revealIntersecting}
233
- <section transition:fly={{ y: 50, delay: 200, duration: 300 }}>
234
- Hello world
235
- </section>
236
- {/if}
237
- </div>
118
+ <IntersectionObserver element={node}>
119
+ {#snippet children({ intersecting })}
120
+ <div bind:this={node}>
121
+ {#if intersecting}
122
+ <div transition:fade={{ delay: 200 }}>Hello world</div>
123
+ {/if}
124
+ </div>
125
+ {/snippet}
238
126
  </IntersectionObserver>
239
127
  ```
240
128
 
241
- ### `use:intersect` action
129
+ #### Props
242
130
 
243
- As an alternative to the `IntersectionObserver` component, use the `intersect` action to observe an element directly with `use:`, without a `bind:this` reference or wrapper markup. Listen for `onobserve`/`onintersect` on the observed element itself.
131
+ | Name | Description | Type | Default value |
132
+ | :----------- | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------ |
133
+ | element | Observed element | `null` or `HTMLElement` | `null` |
134
+ | once | Unobserve the element after the first intersection event | `boolean` | `false` |
135
+ | intersecting | `true` if the observed element is intersecting the viewport | `boolean` | `false` |
136
+ | root | Containing element | `null` or `HTMLElement` | `null` |
137
+ | rootMargin | Margin offset of the containing element | `string` | `"0px"` |
138
+ | threshold | Percentage of element visibility to trigger an event | `number` between 0 and 1, or an array of `number`s between 0 and 1 | `0` |
139
+ | entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) | `null` |
140
+ | observer | `IntersectionObserver` instance | `null` or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | `null` |
141
+ | skip | Pause observing without losing `entry`/`intersecting` state | `boolean` | `false` |
244
142
 
245
- ```svelte
246
- <script>
247
- import { intersect } from "svelte-intersection-observer";
143
+ **Note**: the observed `element` must render with a non-zero width and height for `threshold` values greater than `0` to have any effect. This is a constraint of the underlying [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), not something this component controls.
248
144
 
249
- let actionIntersecting = $state(false);
250
- </script>
145
+ #### Callback props
251
146
 
252
- <header class:intersecting={actionIntersecting}>
253
- {actionIntersecting ? "Element is in view" : "Element is not in view"}
254
- </header>
147
+ Same `onobserve`/`onintersect` behavior as described in [Callbacks](#callbacks-onobserve-and-onintersect) below.
255
148
 
256
- <div
257
- use:intersect={{ once: true }}
258
- onobserve={(e) => (actionIntersecting = e.detail.isIntersecting)}
259
- >
260
- Hello world
261
- </div>
262
- ```
149
+ #### `children` snippet props
263
150
 
264
- Options passed to `use:intersect` are reactive: updating `root`, `rootMargin`, or `threshold` re-initializes the underlying observer. Updating `skip` toggles observing on the existing observer without re-initializing it.
151
+ | Name | Type |
152
+ | :----------- | :------------------------------------------------------------------------------------------------------------------ |
153
+ | intersecting | `boolean` |
154
+ | entry | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) |
155
+ | observer | [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) |
265
156
 
266
- ### Multiple elements
157
+ ### `MultipleIntersectionObserver`
267
158
 
268
- For performance, use `MultipleIntersectionObserver` to observe multiple elements with a single shared observer instead of instantiating a new one for every element.
159
+ For performance, use `MultipleIntersectionObserver` to observe multiple elements with one shared observer instead of instantiating one per element.
269
160
 
270
161
  ```svelte
271
- <script>
162
+ <script lang="ts">
272
163
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";
273
164
 
274
- let ref1 = $state();
275
- let ref2 = $state();
276
-
165
+ let ref1: HTMLElement | undefined = $state();
166
+ let ref2: HTMLElement | undefined = $state();
277
167
  let elements = $derived([ref1, ref2]);
278
168
  </script>
279
169
 
@@ -294,22 +184,21 @@ For performance, use `MultipleIntersectionObserver` to observe multiple elements
294
184
  </MultipleIntersectionObserver>
295
185
  ```
296
186
 
297
- ### Using with `#each`
187
+ #### Using with `#each`
298
188
 
299
189
  `MultipleIntersectionObserver` also handles a dynamic, `#each`-driven list: give every item its own slot in an array/object instead of one shared variable.
300
190
 
301
191
  ```svelte
302
- <script>
192
+ <script lang="ts">
303
193
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";
304
194
 
305
- let items = Array.from({ length: 10 }, (_, i) => ({
195
+ let items = Array.from({ length: 5 }, (_, i) => ({
306
196
  id: i + 1,
307
197
  text: `Item ${i + 1}`,
308
198
  }));
309
199
 
310
- let refs = $state([]);
311
- let itemsContainer = $state();
312
-
200
+ let refs: (HTMLElement | undefined)[] = $state([]);
201
+ let itemsContainer: HTMLElement | undefined = $state();
313
202
  let itemElements = $derived(refs);
314
203
  </script>
315
204
 
@@ -342,49 +231,12 @@ For performance, use `MultipleIntersectionObserver` to observe multiple elements
342
231
 
343
232
  As with the scroll-to-end example, `root` must be an element that scrolls on its own; here, `itemsContainer` has an explicit `height` and `overflow-y: auto`.
344
233
 
345
- **Avoid** using the single-element `IntersectionObserver` component inside an `#each` block with one variable shared across iterations (e.g. `let node;` declared outside the loop and bound via `bind:this={node}` inside it). Every iteration overwrites the same `node`, so each observer instance keeps re-observing a moving target, which can produce an infinite update loop. Use `MultipleIntersectionObserver` with a per-item ref, as shown above, instead.
346
-
347
- ## API
348
-
349
- ### IntersectionObserver
350
-
351
- #### Props
352
-
353
- | Name | Description | Type | Default value |
354
- | :----------- | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------ |
355
- | element | Observed element | `null` or `HTMLElement` | `null` |
356
- | once | Unobserve the element after the first intersection event | `boolean` | `false` |
357
- | intersecting | `true` if the observed element is intersecting the viewport | `boolean` | `false` |
358
- | root | Containing element | `null` or `HTMLElement` | `null` |
359
- | rootMargin | Margin offset of the containing element | `string` | `"0px"` |
360
- | threshold | Percentage of element visibility to trigger an event | `number` between 0 and 1, or an array of `number`s between 0 and 1 | `0` |
361
- | entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) | `null` |
362
- | observer | `IntersectionObserver` instance | `null` or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | `null` |
363
- | skip | Pause observing without losing `entry`/`intersecting` state | `boolean` | `false` |
364
-
365
- **Note**: the observed `element` must render with a non-zero width and height for `threshold` values greater than `0` to have any effect. This is a constraint of the underlying [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), not something this component controls.
366
-
367
- #### Callback props
368
-
369
- - **onobserve**: called when the element is first observed or whenever an intersection change occurs
370
- - **onintersect**: called when the element is intersecting the viewport
371
-
372
- Both callbacks are called with an [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
373
-
374
- #### `children` snippet props
375
-
376
- | Name | Type |
377
- | :----------- | :------------------------------------------------------------------------------------------------------------------ |
378
- | intersecting | `boolean` |
379
- | entry | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) |
380
- | observer | [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) |
381
-
382
- ### MultipleIntersectionObserver
234
+ **Avoid** using the single-element `IntersectionObserver` component inside an `#each` block with one variable shared across iterations (e.g. `let node;` declared outside the loop, bound via `bind:this={node}` inside it). Every iteration overwrites the same `node`, so each observer keeps re-observing a moving target, which can cause an infinite update loop. Use `MultipleIntersectionObserver` with a per-item ref instead.
383
235
 
384
236
  #### Props
385
237
 
386
238
  | Name | Description | Type | Default value |
387
- | :------------------- | :---------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
239
+ | :------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
388
240
  | elements | Array of HTML elements to observe | `Array<HTMLElement \| null>` | `[]` |
389
241
  | once | Unobserve elements after the first intersection event | `boolean` | `false` |
390
242
  | root | Containing element | `null` or `HTMLElement` | `null` |
@@ -397,10 +249,7 @@ Both callbacks are called with an [`IntersectionObserverEntry`](https://develope
397
249
 
398
250
  #### Callback props
399
251
 
400
- - **onobserve**: called when an element is first observed or whenever an intersection change occurs
401
- - **onintersect**: called when an element is intersecting the viewport
402
-
403
- Both callbacks are called with:
252
+ Called with:
404
253
 
405
254
  ```ts
406
255
  {
@@ -409,6 +258,8 @@ Both callbacks are called with:
409
258
  }
410
259
  ```
411
260
 
261
+ See [Callbacks](#callbacks-onobserve-and-onintersect) for when each one fires.
262
+
412
263
  #### `children` snippet props
413
264
 
414
265
  | Name | Type |
@@ -417,7 +268,32 @@ Both callbacks are called with:
417
268
  | elementIntersections | `Map<HTMLElement \| null, boolean>` |
418
269
  | elementEntries | `Map<HTMLElement \| null,` [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry)`>` |
419
270
 
420
- ### `intersect` action
271
+ ### `intersect`
272
+
273
+ As an alternative to the `IntersectionObserver` component, use the `intersect` action to observe an element directly with `use:`, without a `bind:this` reference or extra markup. Listen for `onobserve`/`onintersect` on the observed element itself.
274
+
275
+ ```svelte
276
+ <script lang="ts">
277
+ import { intersect } from "svelte-intersection-observer";
278
+
279
+ let actionIntersecting = $state(false);
280
+ </script>
281
+
282
+ <header class:intersecting={actionIntersecting}>
283
+ {actionIntersecting ? "Element is in view" : "Element is not in view"}
284
+ </header>
285
+
286
+ <div
287
+ use:intersect={{ once: true }}
288
+ onobserve={(e) => {
289
+ actionIntersecting = e.detail.isIntersecting;
290
+ }}
291
+ >
292
+ Hello world
293
+ </div>
294
+ ```
295
+
296
+ Options passed to `use:intersect` are reactive: updating `root`, `rootMargin`, or `threshold` re-initializes the underlying observer. Updating `skip` toggles observing on the existing observer without re-initializing it.
421
297
 
422
298
  #### Options
423
299
 
@@ -431,12 +307,331 @@ Both callbacks are called with:
431
307
 
432
308
  #### Dispatched events
433
309
 
434
- - **onobserve**: fired on the element when it is first observed or whenever an intersection change occurs
435
- - **onintersect**: fired on the element when it is intersecting the viewport
310
+ Same `onobserve`/`onintersect` behavior as described in [Callbacks](#callbacks-onobserve-and-onintersect); the action dispatches them on the element, and `e.detail` is the [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
311
+
312
+ ### `intersectAttachment`
313
+
314
+ As of Svelte 5.29, [attachments](https://svelte.dev/docs/svelte/svelte-attachments) are the preferred replacement for actions. `intersectAttachment` wraps the `intersect` action with `svelte/attachments`'s `fromAction`, reusing the same observer logic but plugging into `{@attach ...}` instead of `use:`.
315
+
316
+ Attachments have a few architectural advantages over actions:
317
+
318
+ - No separate `update()` lifecycle method; they rerun reactively like a `$effect`
319
+ - Just plain functions, so they're easier to compose and generate dynamically
320
+ - Can be forwarded through components as ordinary props, unlike actions
321
+
322
+ ```svelte
323
+ <script lang="ts">
324
+ import { intersectAttachment } from "svelte-intersection-observer";
325
+
326
+ let attachmentIntersecting = $state(false);
327
+ </script>
328
+
329
+ <header class:intersecting={attachmentIntersecting}>
330
+ {attachmentIntersecting ? "Element is in view" : "Element is not in view"}
331
+ </header>
332
+
333
+ <div
334
+ {@attach intersectAttachment(() => ({ once: true }))}
335
+ onobserve={(e) => {
336
+ attachmentIntersecting = e.detail.isIntersecting;
337
+ }}
338
+ >
339
+ Hello world
340
+ </div>
341
+ ```
342
+
343
+ **Note**: unlike `use:intersect`, which takes the options object directly, `intersectAttachment` takes a function that _returns_ the options object (this is how `fromAction` tracks reactive dependencies). `intersect` remains fully supported; use whichever fits your codebase.
344
+
345
+ Options and dispatched events are identical to the [`intersect` action](#intersect) above.
346
+
347
+ ### `createIntersectionObserver`
348
+
349
+ To get intersection state without wrapping markup in a component, use `createIntersectionObserver`, a script-only rune-based composable: call it in `<script>` for reactive `intersecting`/`entry` getters, then apply `attach` to the node with `{@attach}`.
350
+
351
+ ```svelte no-eval
352
+ <script lang="ts">
353
+ import { createIntersectionObserver } from "svelte-intersection-observer";
354
+
355
+ const observer = createIntersectionObserver(() => ({ threshold: 0.5 }));
356
+ </script>
357
+
358
+ <div {@attach observer.attach}>
359
+ {observer.intersecting ? "In view" : "Not in view"}
360
+ </div>
361
+ ```
362
+
363
+ `createIntersectionObserver` takes the same options as [`intersectAttachment`](#intersectattachment) (as a function returning the options object) and reuses its underlying observer logic.
364
+
365
+ #### Return value
366
+
367
+ | Name | Description | Type |
368
+ | :----------- | :------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ |
369
+ | intersecting | `true` if the observed element is intersecting the viewport | `boolean` |
370
+ | entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) |
371
+ | attach | Attachment to apply to the observed element via `{@attach}` | [`Attachment<HTMLElement>`](https://svelte.dev/docs/svelte/svelte-attachments) |
372
+
373
+ ### `createIntersectionGroup`
374
+
375
+ A bare `intersect`/`intersectAttachment` inside an `#each` block creates one native `IntersectionObserver` per iteration: for a long list, that's N observers instead of 1. `createIntersectionGroup` fixes this for the action/attachment API: call it once to create a group, then call `group.attach(...)` once per element to get an attachment that shares a single underlying observer across the whole group.
376
+
377
+ ```svelte
378
+ <script lang="ts">
379
+ import { createIntersectionGroup } from "svelte-intersection-observer";
380
+
381
+ let groupItems = $state(
382
+ Array.from({ length: 5 }, (_, i) => ({ id: i, intersecting: false })),
383
+ );
384
+
385
+ const group = createIntersectionGroup();
386
+ </script>
387
+
388
+ <header>
389
+ {#each groupItems as item (item.id)}
390
+ <div class:intersecting={item.intersecting}>
391
+ Item {item.id}: {item.intersecting ? "✓" : "✗"}
392
+ </div>
393
+ {/each}
394
+ </header>
395
+
396
+ {#each groupItems as item (item.id)}
397
+ <div
398
+ class:intersecting={item.intersecting}
399
+ {@attach group.attach({
400
+ onobserve: (entry) => (item.intersecting = entry.isIntersecting),
401
+ })}
402
+ >
403
+ Item {item.id}
404
+ </div>
405
+ {/each}
406
+ ```
407
+
408
+ `root`/`rootMargin`/`threshold` configure the one shared observer, so they're passed once to `createIntersectionGroup` itself (as a function, for the same reactive-dependency-tracking reason `intersectAttachment` takes one) rather than per element:
409
+
410
+ ```js
411
+ const group = createIntersectionGroup(() => ({
412
+ root: container,
413
+ rootMargin: "0px",
414
+ threshold: 0.5,
415
+ }));
416
+ ```
417
+
418
+ `once`, `skip`, `onobserve`, and `onintersect` are the only options that make sense per element, so those are what `group.attach(...)` accepts.
419
+
420
+ #### Signature
421
+
422
+ ```ts
423
+ function createIntersectionGroup(
424
+ getSharedOptions?: () => IntersectGroupSharedOptions,
425
+ ): IntersectionGroup;
426
+ ```
427
+
428
+ #### Shared options
429
+
430
+ Passed once to `createIntersectionGroup`; apply to every element in the group.
431
+
432
+ | Name | Description | Type | Default value |
433
+ | :--------- | :----------------------------------------------------- | :----------------------------------------------------------------- | :------------ |
434
+ | root | Containing element | `null` or `HTMLElement` | `null` |
435
+ | rootMargin | Margin offset of the containing element | `string` | `"0px"` |
436
+ | threshold | Percentage of element visibility to trigger an event | `number` between 0 and 1, or an array of `number`s between 0 and 1 | `0` |
437
+
438
+ #### `group.attach(options)` per-node options
439
+
440
+ Passed once per element, to `group.attach(...)`.
441
+
442
+ | Name | Description | Type | Default value |
443
+ | :--------- | :---------------------------------------------------------- | :------------------------------------------------------- | :------------ |
444
+ | once | Unobserve the element after the first intersection event | `boolean` | `false` |
445
+ | skip | Skip observing this element without affecting the group | `boolean` | `false` |
446
+ | onobserve | Called when the element is first observed or when an intersection change occurs | `(entry: IntersectionObserverEntry) => void` | `undefined` |
447
+ | onintersect | Called when the element is intersecting the viewport | `(entry: IntersectionObserverEntry) => void` | `undefined` |
448
+
449
+ #### Callbacks: `onobserve` and `onintersect`
450
+
451
+ Every primitive above exposes the same two callbacks, called with an [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) (components pass it directly; action and attachment dispatch it as `event.detail`):
452
+
453
+ - **onobserve**: called when the element is first observed, and again on every intersection change
454
+ - **onintersect**: called only when the element is intersecting the viewport (a filtered view of `onobserve`)
455
+
456
+ ```svelte no-eval
457
+ <IntersectionObserver
458
+ {element}
459
+ onobserve={(entry) => {
460
+ console.log(entry); // IntersectionObserverEntry
461
+ console.log(entry.isIntersecting); // true | false
462
+ }}
463
+ onintersect={(entry) => {
464
+ console.log(entry.isIntersecting); // always true
465
+ }}
466
+ >
467
+ <div bind:this={element}>Hello world</div>
468
+ </IntersectionObserver>
469
+ ```
470
+
471
+ ## Use Cases
472
+
473
+ Realistic scenarios built from the primitives above.
474
+
475
+ ### Lazy-loading images
476
+
477
+ Delay loading an image's real `src` until it's about to scroll into view. `rootMargin` starts the fetch slightly before the image is visible so it's ready when the user scrolls to it; `once` stops observing once it has loaded.
478
+
479
+ ```svelte no-eval
480
+ <script lang="ts">
481
+ import { intersectAttachment } from "svelte-intersection-observer";
482
+
483
+ let loaded = $state(false);
484
+ </script>
485
+
486
+ <img
487
+ {@attach intersectAttachment(() => ({ once: true, rootMargin: "200px" }))}
488
+ onintersect={() => (loaded = true)}
489
+ src={loaded ? "/photo.jpg" : "/placeholder.jpg"}
490
+ alt=""
491
+ />
492
+ ```
493
+
494
+ ### Autoplaying video
495
+
496
+ Play a `<video>` while it's on screen and pause it once it scrolls away. Unlike lazy-loading, this needs to react every time visibility changes, so use `onobserve` (not `onintersect`) and skip `once`.
497
+
498
+ ```svelte no-eval
499
+ <script lang="ts">
500
+ import { intersect } from "svelte-intersection-observer";
501
+
502
+ let video: HTMLVideoElement | undefined = $state();
503
+ </script>
504
+
505
+ <video
506
+ bind:this={video}
507
+ use:intersect
508
+ onobserve={(e) => {
509
+ e.detail.isIntersecting ? video?.play() : video?.pause();
510
+ }}
511
+ src="/clip.mp4"
512
+ muted
513
+ loop
514
+ ></video>
515
+ ```
516
+
517
+ ### Tracking impressions
518
+
519
+ Fire an impression event the first time an element is meaningfully visible. `threshold` sets what counts as "visible enough," and `once` guarantees a single event per element.
520
+
521
+ ```svelte no-eval
522
+ <div
523
+ use:intersect={{ once: true, threshold: 0.5 }}
524
+ onintersect={() => analytics.track("ad_impression", { id: "banner-1" })}
525
+ >
526
+ <!-- ad content -->
527
+ </div>
528
+ ```
529
+
530
+ ### Infinite scroll
531
+
532
+ To detect when a user has scrolled to the end of a scrollable container, place a sentinel element after the content and set `root` to the container. `intersecting` becomes `true` once the sentinel scrolls into view.
533
+
534
+ **Note**: `root` must be the scrollable element itself (i.e. it has its own `overflow`/fixed `height`), not just an ancestor of one. If `root` merely sits inside a scrollable ancestor, the sentinel scrolls along with `root` and never changes position relative to it, so it reports as permanently intersecting.
535
+
536
+ ```svelte
537
+ <script lang="ts">
538
+ import IntersectionObserver from "svelte-intersection-observer";
539
+
540
+ let container: HTMLElement | undefined = $state();
541
+ let sentinel: HTMLElement | undefined = $state();
542
+ let reachedEnd = $state(false);
543
+ </script>
544
+
545
+ <header class:intersecting={reachedEnd}>
546
+ {reachedEnd ? "You've reached the end" : "Keep scrolling..."}
547
+ </header>
548
+
549
+ <div bind:this={container} style="height: 200px; overflow-y: auto;">
550
+ {#each Array.from({ length: 20 }) as _, i}
551
+ <p>Paragraph {i + 1}</p>
552
+ {/each}
553
+
554
+ <IntersectionObserver
555
+ element={sentinel}
556
+ root={container}
557
+ bind:intersecting={reachedEnd}
558
+ >
559
+ <div bind:this={sentinel} style="height: 1px;"></div>
560
+ </IntersectionObserver>
561
+ </div>
562
+ ```
563
+
564
+ The same sentinel pattern powers infinite scroll: call a `loadMore()` function from `onintersect` instead of (or alongside) updating `reachedEnd`.
565
+
566
+ ### Reveal animation on scroll
567
+
568
+ Keep the `bind:this` element outside the `{#if intersecting}` block, and only gate the animated content inside it. The bound element stays in the DOM even before the reveal fires, so external triggers like `scrollIntoView()` still work, and the animation replays every time the element crosses into or out of view.
569
+
570
+ ```svelte
571
+ <script lang="ts">
572
+ import IntersectionObserver from "svelte-intersection-observer";
573
+ import { fly } from "svelte/transition";
574
+
575
+ let revealNode: HTMLElement | undefined = $state();
576
+ let revealIntersecting = $state(false);
577
+ </script>
578
+
579
+ <header class:intersecting={revealIntersecting}>
580
+ <p>{revealIntersecting ? "In view" : "Not in view"}</p>
581
+ <button
582
+ style="margin-top: 0.75rem;"
583
+ onclick={() => {
584
+ revealNode?.scrollIntoView({ behavior: "smooth", block: "nearest" });
585
+ }}
586
+ >
587
+ Scroll to section
588
+ </button>
589
+ </header>
590
+
591
+ <IntersectionObserver element={revealNode} bind:intersecting={revealIntersecting}>
592
+ <div bind:this={revealNode}>
593
+ {#if revealIntersecting}
594
+ <section transition:fly={{ y: 50, duration: 300 }}>
595
+ Hello world
596
+ </section>
597
+ {/if}
598
+ </div>
599
+ </IntersectionObserver>
600
+ ```
601
+
602
+ ### `skip`
603
+
604
+ Set `skip` to `true` to unobserve without disconnecting the underlying observer or losing `entry`/`intersecting` state. Useful for pausing tracking on an off-screen carousel panel or a closed modal. Set `skip` back to `false` to resume; unlike `once`, this toggles back and forth freely. `MultipleIntersectionObserver` and the `intersect` action support the same `skip` option.
605
+
606
+ ```svelte no-eval
607
+ <script lang="ts">
608
+ import IntersectionObserver from "svelte-intersection-observer";
609
+
610
+ let elementSkip: HTMLElement | undefined = $state();
611
+ let paused = $state(false);
612
+ </script>
613
+
614
+ <button onclick={() => (paused = !paused)}>
615
+ {paused ? "Resume" : "Pause"}
616
+ </button>
617
+
618
+ <IntersectionObserver element={elementSkip} skip={paused}>
619
+ {#snippet children({ intersecting })}
620
+ <div bind:this={elementSkip}>{intersecting ? "In view" : "Not in view"}</div>
621
+ {/snippet}
622
+ </IntersectionObserver>
623
+ ```
624
+
625
+ ### List strategy
626
+
627
+ Both [`MultipleIntersectionObserver`](#multipleintersectionobserver) and [`createIntersectionGroup`](#createintersectiongroup) share one observer across many elements. Pick based on how you want to wire it up:
628
+
629
+ - Use `MultipleIntersectionObserver` when you're fine wrapping the list in a component and want a ready-made `elementIntersections` map handed to you via the `children` snippet.
630
+ - Use `createIntersectionGroup` when you'd rather attach directly to each element with `{@attach}`, no extra component, and are happy tracking intersection state on your own per-item objects (as shown in its example above).
436
631
 
437
- The `e.detail` dispatched by the `observe` and `intersect` events is an [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) interface.
632
+ Either way, avoid giving `IntersectionObserver` a single shared `bind:this` variable inside `#each`; see the warning under [pooled component](#multipleintersectionobserver).
438
633
 
439
- ### `IntersectionObserverEntry` interface
634
+ ## `IntersectionObserverEntry`
440
635
 
441
636
  Note that all properties in [IntersectionObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) are read-only.
442
637