svelte-intersection-observer 2.1.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
@@ -4,24 +4,28 @@
4
4
  [![NPM][npm]][npm-url]
5
5
  <!-- HIDE_END -->
6
6
 
7
- > 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
8
8
 
9
- 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)).
10
10
 
11
- This zero-dependency library offers several ways to observe elements:
11
+ It offers six interchangeable primitives, all backed by the same shared observer logic.
12
12
 
13
- - `IntersectionObserver`: component for observing a single element
14
- - `MultipleIntersectionObserver`: component for observing multiple elements (shared observer for better performance)
15
- - `intersect`: action for observing an element directly with `use:`
16
- - `intersectAttachment`: attachment for observing an element directly with `{@attach}` (Svelte 5.29+)
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 |
17
21
 
18
- Try it in the [Svelte REPL](https://svelte.dev/repl/8cd2327a580c4f429c71f7df999bd51d).
22
+ See [Library](#library) for the full docs on each. Try it in the [Svelte REPL](https://svelte.dev/repl/8cd2327a580c4f429c71f7df999bd51d).
19
23
 
20
- ## Compatibility
24
+ ### Compatibility
21
25
 
22
26
  | Package version | Svelte version | Notes |
23
27
  | :--------------- | :----------------- | :----------------------------------------- |
24
- | 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 |
25
29
  | 2.x | 5 (runes mode only) | Uses `$props()`, snippets, and callback props |
26
30
 
27
31
  <!-- TOC -->
@@ -43,19 +47,21 @@ yarn add svelte-intersection-observer
43
47
 
44
48
  ```
45
49
 
46
- ## Usage
50
+ ## Library
47
51
 
48
- ### Basic
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.
53
+
54
+ ### `IntersectionObserver`
49
55
 
50
56
  Use the [`bind:this`](https://svelte.dev/docs#bind_element) directive to pass an element reference to the `IntersectionObserver` component.
51
57
 
52
- 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.
53
59
 
54
60
  ```svelte
55
- <script>
61
+ <script lang="ts">
56
62
  import IntersectionObserver from "svelte-intersection-observer";
57
63
 
58
- let element = $state();
64
+ let element: HTMLElement | undefined = $state();
59
65
  let intersecting = $state(false);
60
66
  </script>
61
67
 
@@ -68,42 +74,15 @@ Then, simply bind to the reactive `intersecting` prop to determine if the elemen
68
74
  </IntersectionObserver>
69
75
  ```
70
76
 
71
- ### `children` snippet
72
-
73
- An alternative to binding to the `intersecting` prop is to use the `children` snippet, which receives `intersecting`, `entry`, and `observer`.
74
-
75
- In the following example, the "Hello world" element fades in when its containing element intersects the viewport.
76
-
77
- ```svelte
78
- <script>
79
- import IntersectionObserver from "svelte-intersection-observer";
80
- import { fade } from "svelte/transition";
81
-
82
- let node = $state();
83
- </script>
84
-
85
- <header></header>
86
-
87
- <IntersectionObserver element={node}>
88
- {#snippet children({ intersecting })}
89
- <div bind:this={node}>
90
- {#if intersecting}
91
- <div transition:fade={{ delay: 200 }}>Hello world</div>
92
- {/if}
93
- </div>
94
- {/snippet}
95
- </IntersectionObserver>
96
- ```
97
-
98
- ### Once
77
+ #### `once`
99
78
 
100
- 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.
101
80
 
102
81
  ```svelte
103
- <script>
82
+ <script lang="ts">
104
83
  import IntersectionObserver from "svelte-intersection-observer";
105
84
 
106
- let elementOnce = $state();
85
+ let elementOnce: HTMLElement | undefined = $state();
107
86
  let intersectOnce = $state(false);
108
87
  </script>
109
88
 
@@ -120,198 +99,71 @@ Set `once` to `true` for the intersection event to occur only once. The `element
120
99
  </IntersectionObserver>
121
100
  ```
122
101
 
123
- ### Pausing with `skip`
124
-
125
- 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.
126
-
127
- ```svelte no-eval
128
- <script>
129
- import IntersectionObserver from "svelte-intersection-observer";
130
-
131
- let elementSkip = $state();
132
- let paused = $state(false);
133
- </script>
134
-
135
- <button onclick={() => (paused = !paused)}>
136
- {paused ? "Resume" : "Pause"}
137
- </button>
138
-
139
- <IntersectionObserver element={elementSkip} skip={paused}>
140
- {#snippet children({ intersecting })}
141
- <div bind:this={elementSkip}>{intersecting ? "In view" : "Not in view"}</div>
142
- {/snippet}
143
- </IntersectionObserver>
144
- ```
145
-
146
- ### `onobserve` callback prop
147
-
148
- `onobserve` is called when the element is first observed and also whenever an intersection event occurs.
149
-
150
- ```svelte no-eval
151
- <IntersectionObserver
152
- {element}
153
- onobserve={(entry) => {
154
- console.log(entry); // IntersectionObserverEntry
155
- console.log(entry.isIntersecting); // true | false
156
- }}
157
- >
158
- <div bind:this={element}>Hello world</div>
159
- </IntersectionObserver>
160
- ```
102
+ #### `children` snippet
161
103
 
162
- ### `onintersect` callback prop
163
-
164
- 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.
165
-
166
- **Note**: Compared to `onobserve`, `onintersect` is called only when the element is _intersecting the viewport_. In other words, `entry.isIntersecting` will only be `true`.
167
-
168
- ```svelte no-eval
169
- <IntersectionObserver
170
- {element}
171
- onintersect={(entry) => {
172
- console.log(entry); // IntersectionObserverEntry
173
- console.log(entry.isIntersecting); // true
174
- }}
175
- >
176
- <div bind:this={element}>Hello world</div>
177
- </IntersectionObserver>
178
- ```
179
-
180
- ### Detecting scroll to end
181
-
182
- 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.
183
-
184
- **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.
185
-
186
- ```svelte
187
- <script>
188
- import IntersectionObserver from "svelte-intersection-observer";
189
-
190
- let container = $state();
191
- let sentinel = $state();
192
- let reachedEnd = $state(false);
193
- </script>
194
-
195
- <header class:intersecting={reachedEnd}>
196
- {reachedEnd ? "You've reached the end" : "Keep scrolling..."}
197
- </header>
198
-
199
- <div bind:this={container} style="height: 200px; overflow-y: auto;">
200
- {#each Array.from({ length: 20 }) as _, i}
201
- <p>Paragraph {i + 1}</p>
202
- {/each}
203
-
204
- <IntersectionObserver
205
- element={sentinel}
206
- root={container}
207
- bind:intersecting={reachedEnd}
208
- >
209
- <div bind:this={sentinel} style="height: 1px;"></div>
210
- </IntersectionObserver>
211
- </div>
212
- ```
213
-
214
- ### 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`.
215
105
 
216
- 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.
217
107
 
218
108
  ```svelte
219
- <script>
109
+ <script lang="ts">
220
110
  import IntersectionObserver from "svelte-intersection-observer";
221
- import { fly } from "svelte/transition";
111
+ import { fade } from "svelte/transition";
222
112
 
223
- let revealNode = $state();
224
- let revealIntersecting = $state(false);
113
+ let node: HTMLElement | undefined = $state();
225
114
  </script>
226
115
 
227
- <header class:intersecting={revealIntersecting}>
228
- <button
229
- onclick={() => {
230
- revealNode.scrollIntoView({ behavior: "smooth", block: "nearest" });
231
- }}
232
- >
233
- Scroll to section
234
- </button>
235
- </header>
116
+ <header></header>
236
117
 
237
- <IntersectionObserver element={revealNode} once bind:intersecting={revealIntersecting}>
238
- <div bind:this={revealNode}>
239
- {#if revealIntersecting}
240
- <section transition:fly={{ y: 50, delay: 200, duration: 300 }}>
241
- Hello world
242
- </section>
243
- {/if}
244
- </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}
245
126
  </IntersectionObserver>
246
127
  ```
247
128
 
248
- ### `use:intersect` action
249
-
250
- 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.
251
-
252
- ```svelte
253
- <script>
254
- import { intersect } from "svelte-intersection-observer";
255
-
256
- let actionIntersecting = $state(false);
257
- </script>
258
-
259
- <header class:intersecting={actionIntersecting}>
260
- {actionIntersecting ? "Element is in view" : "Element is not in view"}
261
- </header>
262
-
263
- <div
264
- use:intersect={{ once: true }}
265
- onobserve={(e) => (actionIntersecting = e.detail.isIntersecting)}
266
- >
267
- Hello world
268
- </div>
269
- ```
270
-
271
- 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.
272
-
273
- ### `intersectAttachment` attachment
274
-
275
- 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:`.
276
-
277
- Attachments have a few architectural advantages over actions:
129
+ #### Props
278
130
 
279
- - No separate `update()` lifecycle method; they rerun reactively like a `$effect`
280
- - Just plain functions, so they're easier to compose and generate dynamically
281
- - Can be forwarded through components as ordinary props, unlike actions
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` |
282
142
 
283
- ```svelte
284
- <script>
285
- import { intersectAttachment } 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.
286
144
 
287
- let attachmentIntersecting = $state(false);
288
- </script>
145
+ #### Callback props
289
146
 
290
- <header class:intersecting={attachmentIntersecting}>
291
- {attachmentIntersecting ? "Element is in view" : "Element is not in view"}
292
- </header>
147
+ Same `onobserve`/`onintersect` behavior as described in [Callbacks](#callbacks-onobserve-and-onintersect) below.
293
148
 
294
- <div
295
- {@attach intersectAttachment(() => ({ once: true }))}
296
- onobserve={(e) => (attachmentIntersecting = e.detail.isIntersecting)}
297
- >
298
- Hello world
299
- </div>
300
- ```
149
+ #### `children` snippet props
301
150
 
302
- **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.
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) |
303
156
 
304
- ### Multiple elements
157
+ ### `MultipleIntersectionObserver`
305
158
 
306
- 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.
307
160
 
308
161
  ```svelte
309
- <script>
162
+ <script lang="ts">
310
163
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";
311
164
 
312
- let ref1 = $state();
313
- let ref2 = $state();
314
-
165
+ let ref1: HTMLElement | undefined = $state();
166
+ let ref2: HTMLElement | undefined = $state();
315
167
  let elements = $derived([ref1, ref2]);
316
168
  </script>
317
169
 
@@ -332,22 +184,21 @@ For performance, use `MultipleIntersectionObserver` to observe multiple elements
332
184
  </MultipleIntersectionObserver>
333
185
  ```
334
186
 
335
- ### Using with `#each`
187
+ #### Using with `#each`
336
188
 
337
189
  `MultipleIntersectionObserver` also handles a dynamic, `#each`-driven list: give every item its own slot in an array/object instead of one shared variable.
338
190
 
339
191
  ```svelte
340
- <script>
192
+ <script lang="ts">
341
193
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";
342
194
 
343
- let items = Array.from({ length: 10 }, (_, i) => ({
195
+ let items = Array.from({ length: 5 }, (_, i) => ({
344
196
  id: i + 1,
345
197
  text: `Item ${i + 1}`,
346
198
  }));
347
199
 
348
- let refs = $state([]);
349
- let itemsContainer = $state();
350
-
200
+ let refs: (HTMLElement | undefined)[] = $state([]);
201
+ let itemsContainer: HTMLElement | undefined = $state();
351
202
  let itemElements = $derived(refs);
352
203
  </script>
353
204
 
@@ -380,49 +231,12 @@ For performance, use `MultipleIntersectionObserver` to observe multiple elements
380
231
 
381
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`.
382
233
 
383
- **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.
384
-
385
- ## API
386
-
387
- ### IntersectionObserver
388
-
389
- #### Props
390
-
391
- | Name | Description | Type | Default value |
392
- | :----------- | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------ |
393
- | element | Observed element | `null` or `HTMLElement` | `null` |
394
- | once | Unobserve the element after the first intersection event | `boolean` | `false` |
395
- | intersecting | `true` if the observed element is intersecting the viewport | `boolean` | `false` |
396
- | root | Containing element | `null` or `HTMLElement` | `null` |
397
- | rootMargin | Margin offset of the containing element | `string` | `"0px"` |
398
- | 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` |
399
- | entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) | `null` |
400
- | observer | `IntersectionObserver` instance | `null` or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | `null` |
401
- | skip | Pause observing without losing `entry`/`intersecting` state | `boolean` | `false` |
402
-
403
- **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.
404
-
405
- #### Callback props
406
-
407
- - **onobserve**: called when the element is first observed or whenever an intersection change occurs
408
- - **onintersect**: called when the element is intersecting the viewport
409
-
410
- Both callbacks are called with an [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
411
-
412
- #### `children` snippet props
413
-
414
- | Name | Type |
415
- | :----------- | :------------------------------------------------------------------------------------------------------------------ |
416
- | intersecting | `boolean` |
417
- | entry | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) |
418
- | observer | [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) |
419
-
420
- ### 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.
421
235
 
422
236
  #### Props
423
237
 
424
238
  | Name | Description | Type | Default value |
425
- | :------------------- | :---------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
239
+ | :------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
426
240
  | elements | Array of HTML elements to observe | `Array<HTMLElement \| null>` | `[]` |
427
241
  | once | Unobserve elements after the first intersection event | `boolean` | `false` |
428
242
  | root | Containing element | `null` or `HTMLElement` | `null` |
@@ -435,10 +249,7 @@ Both callbacks are called with an [`IntersectionObserverEntry`](https://develope
435
249
 
436
250
  #### Callback props
437
251
 
438
- - **onobserve**: called when an element is first observed or whenever an intersection change occurs
439
- - **onintersect**: called when an element is intersecting the viewport
440
-
441
- Both callbacks are called with:
252
+ Called with:
442
253
 
443
254
  ```ts
444
255
  {
@@ -447,6 +258,8 @@ Both callbacks are called with:
447
258
  }
448
259
  ```
449
260
 
261
+ See [Callbacks](#callbacks-onobserve-and-onintersect) for when each one fires.
262
+
450
263
  #### `children` snippet props
451
264
 
452
265
  | Name | Type |
@@ -455,9 +268,32 @@ Both callbacks are called with:
455
268
  | elementIntersections | `Map<HTMLElement \| null, boolean>` |
456
269
  | elementEntries | `Map<HTMLElement \| null,` [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry)`>` |
457
270
 
458
- ### `intersect` action / `intersectAttachment` attachment
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
+ ```
459
295
 
460
- `intersectAttachment` is a thin wrapper around the `intersect` action (see [above](#intersectattachment-attachment)), so both share the same options and dispatched events.
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.
461
297
 
462
298
  #### Options
463
299
 
@@ -471,12 +307,331 @@ Both callbacks are called with:
471
307
 
472
308
  #### Dispatched events
473
309
 
474
- - **onobserve**: fired on the element when it is first observed or whenever an intersection change occurs
475
- - **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).
476
631
 
477
- 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).
478
633
 
479
- ### `IntersectionObserverEntry` interface
634
+ ## `IntersectionObserverEntry`
480
635
 
481
636
  Note that all properties in [IntersectionObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) are read-only.
482
637