diff --git a/README.md b/README.md index fd62e26..4a783bb 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ ## About -`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)). +`svelte-intersection-observer` wraps the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). It tells you when an element enters or leaves the viewport, without scroll listeners. Typical uses include lazy-loading, scroll animations, infinite scroll, autoplaying video, and impression tracking. See [Use cases](#use-cases). -It offers six interchangeable primitives, all backed by the same shared observer logic. +Six exports, pick by how you want to wire it up: -| Primitive | Export | Use it when... | +| Kind | Export | Use it when... | | :-------- | :----- | :--------------------- | | [Component](#intersectionobserver) | `IntersectionObserver` | you want a component with a bound `intersecting` prop | | [Pooled component](#multipleintersectionobserver) | `MultipleIntersectionObserver` | you're observing many elements and want one shared observer | @@ -19,7 +19,7 @@ It offers six interchangeable primitives, all backed by the same shared observer | [Composable](#createintersectionobserver) | `createIntersectionObserver` | you want reactive state from ` - + {#snippet children({ elementIntersections })}
{#each items as item, i (item.id)} @@ -231,198 +251,176 @@ For performance, use `MultipleIntersectionObserver` to observe multiple elements ``` -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`. +Same rule as the scroll-to-end example: `root` must scroll on its own. Here `itemsContainer` has an explicit `height` and `overflow-y: auto`. -**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. +**Avoid** putting the single-element `IntersectionObserver` inside an `#each` with one variable shared across iterations, such as `let node;` outside the loop and `bind:this={node}` inside. Every iteration overwrites `node`, so each observer keeps chasing a moving target and can loop forever. Use `MultipleIntersectionObserver` with a per-item ref instead. #### Props -| Name | Description | Type | Default value | -| :------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | :------------ | -| elements | Array of elements to observe | `ReadonlyArray` | `[]` | -| once | Unobserve elements after the first intersection event | `boolean` | `false` | -| root | Containing element | `Element` \| `Document` \| `null` | `null` | -| rootMargin | Margin offset of the containing element | `string` | `"0px"` | -| 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` | -| elementIntersections | Map of each element to its intersection state | `Map` | `new Map()` | -| elementEntries | Map of each element to its latest entry | `Map` | `new Map()` | -| observer | `IntersectionObserver` instance | `null` or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | `null` | -| skip | Pause observing all elements without losing state | `boolean` | `false` | +| Name | Description | Type | Default value | +| :-------------------- | :--------------------------------------------------------------------- | :---------------------------------------------------------------- | :------------ | +| elements | Array of elements to observe | `Array` | `[]` | +| root | Containing element | `Element` \| `Document` \| `null` | `null` | +| rootMargin | Margin offset of the containing element | `string` | `"0px"` | +| 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` | +| once | Unobserve each element after its first intersection event | `boolean` | `false` | +| skip | Pause observing all elements without clearing current state | `boolean` | `false` | +| elementIntersections | Map of each element to its current intersecting state | `Map` | `new Map()` | +| elementEntries | Map of each element to its latest observer entry | `Map` | `new Map()` | +| observer | Shared `IntersectionObserver` instance | `null` or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | `null` | #### Callback props -Called with: - -```ts -{ - entry: IntersectionObserverEntry; - target: HTMLElement; -} -``` - -See [Callbacks](#callbacks-onobserve-onintersect-and-onexit) for when each one fires. +Same `onobserve`/`onintersect`/`onexit` behavior as described in [Callbacks](#callbacks-onobserve-onintersect-and-onexit) above. Each callback receives the element's individual `IntersectionObserverEntry`. #### `children` snippet props -| Name | Type | -| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| observer | [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | -| elementIntersections | `Map` | -| elementEntries | `Map` | +| Name | Type | +| :------------------- | :----------------------------------------------------------------------------------------------- | +| elementIntersections | `Map` | +| elementEntries | `Map` | +| observer | [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) | ### `intersect` -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`/`onexit` on the observed element itself. +Use the `intersect` action to observe an element with `use:` and no wrapper component. -```svelte +```svelte no-eval -
- {actionIntersecting ? "Element is in view" : "Element is not in view"} -
-
{ - actionIntersecting = e.detail.isIntersecting; + inView = e.detail.isIntersecting; }} > - Hello world + {inView ? "In view" : "Not in view"}
``` -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. - -#### Options - -| Name | Description | Type | Default value | -| :--------- | :------------------------------------------------------- | :----------------------------------------------------------------- | :------------ | -| root | Containing element | `null` or `HTMLElement` | `null` | -| rootMargin | Margin offset of the containing element | `string` | `"0px"` | -| 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` | -| once | Unobserve the element after the first intersection event | `boolean` | `false` | -| skip | Pause observing without disconnecting the observer | `boolean` | `false` | +#### Parameters -#### Dispatched events - -Same `onobserve`/`onintersect`/`onexit` behavior as described in [Callbacks](#callbacks-onobserve-onintersect-and-onexit); the action dispatches them on the element, and `e.detail` is the [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry). +| Name | Description | Type | Default value | +| :--------- | :-------------------------------------------------------- | :------------------------------------------------------ | :------------ | +| once | Unobserve the element after the first intersection event | `boolean` | `false` | +| skip | Pause observing without losing `entry`/`intersecting` state | `boolean` | `false` | +| root | Containing element | `Element` \| `Document` \| `null` | `null` | +| rootMargin | Margin offset of the containing element | `string` | `"0px"` | +| 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` | +| onobserve | Called when the element is first observed or when an intersection change occurs | `(entry: IntersectionObserverEntry) => void` | `undefined` | +| onintersect | Called when the element is intersecting the viewport | `(entry: IntersectionObserverEntry) => void` | `undefined` | +| onexit | Called when the element stops intersecting | `(entry: IntersectionObserverEntry) => void` | `undefined` | ### `intersectAttachment` -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:`. - -Attachments have a few architectural advantages over actions: +Use `intersectAttachment` in Svelte 5.29+ if you prefer `{@attach}` over `use:`. -- No separate `update()` lifecycle method; they rerun reactively like a `$effect` -- Just plain functions, so they're easier to compose and generate dynamically -- Can be forwarded through components as ordinary props, unlike actions - -```svelte +```svelte no-eval -
- {attachmentIntersecting ? "Element is in view" : "Element is not in view"} -
-
({ once: true }))} + {@attach intersectAttachment()} onobserve={(e) => { - attachmentIntersecting = e.detail.isIntersecting; + inView = e.detail.isIntersecting; }} > - Hello world + {inView ? "In view" : "Not in view"}
``` -**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. +#### Parameters -Options and dispatched events are identical to the [`intersect` action](#intersect) above. +Same options and callbacks as [`intersect`](#intersect). ### `createIntersectionObserver` -To get intersection state without wrapping markup in a component, use `createIntersectionObserver`, a script-only rune-based composable: call it in ` -
- {observer.intersecting ? "In view" : "Not in view"} +
+ {observer.intersecting ? "Half visible" : "Less than half visible"}
``` -`createIntersectionObserver` takes the same options as [`intersectAttachment`](#intersectattachment) (as a function returning the options object) and reuses its underlying observer logic. +#### Signature + +```ts +function createIntersectionObserver( + getOptions?: () => IntersectOptions, +): { + readonly intersecting: boolean; + readonly entry: IntersectionObserverEntry | null; + readonly observer: IntersectionObserver | null; +}; +``` + +#### Options -**Note**: the returned `intersecting`/`entry` are plain getters backed by runes. They only stay reactive when read from a runes-mode component — a non-runes ("legacy") consumer won't re-render when they change, since its template doesn't track getter reads. If you need this to work from a legacy component, use one of the other primitives (e.g. [`intersectAttachment`](#intersectattachment) with its `onobserve` callback) instead. +Same core options as [`IntersectionObserver`](#intersectionobserver): `element`, `root`, `rootMargin`, `threshold`, `once`, and `skip`. -#### Return value +#### Callback options -| Name | Description | Type | -| :----------- | :------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | -| intersecting | `true` if the observed element is intersecting the viewport | `boolean` | -| entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) | -| attach | Attachment to apply to the observed element via `{@attach}` | [`Attachment`](https://svelte.dev/docs/svelte/svelte-attachments) | +Same `onobserve`/`onintersect`/`onexit` behavior as described in [Callbacks](#callbacks-onobserve-onintersect-and-onexit) above. ### `createIntersectionGroup` -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. +Use `createIntersectionGroup` for one shared observer across many elements, attaching to each node with `{@attach}`. -```svelte +```svelte no-eval -
- {#each groupItems as item (item.id)} -
- Item {item.id}: {item.intersecting ? "✓" : "✗"} -
+
+ {#each sections as section (section.id)} +
{ + section.visible = entry.isIntersecting; + }, + })} + > +

{section.title}

+

{section.visible ? "Visible" : "Not visible"}

+
{/each} -
- -{#each groupItems as item (item.id)} -
(item.intersecting = entry.isIntersecting), - })} - > - Item {item.id} -
-{/each} -``` - -`root`/`rootMargin`/`threshold` configure the one shared observer, so they're passed once to `createIntersectionGroup` itself (as a function) rather than per element: - -```js -const group = createIntersectionGroup(() => ({ - root: container, - rootMargin: "0px", - threshold: 0.5, -})); +
``` -Shared options are reactive: when `root`, `rootMargin`, or `threshold` changes, the group rebuilds its single shared observer and re-observes every element. Note that elements whose `once` has already fired are re-observed as well. - -`once`, `skip`, `onobserve`, `onintersect`, and `onexit` are the only options that make sense per element, so those are what `group.attach(...)` accepts. - #### Signature ```ts @@ -433,7 +431,7 @@ function createIntersectionGroup( #### Shared options -Passed once to `createIntersectionGroup`; apply to every element in the group. +Passed once to `createIntersectionGroup`. Apply to every element in the group. | Name | Description | Type | Default value | | :--------- | :----------------------------------------------------- | :----------------------------------------------------------------- | :------------ | @@ -446,46 +444,24 @@ Passed once to `createIntersectionGroup`; apply to every element in the group. Passed once per element, to `group.attach(...)`. | Name | Description | Type | Default value | -| :--------- | :---------------------------------------------------------- | :------------------------------------------------------- | :------------ | +| :--------- | :-------------------------------------------------------- | :------------------------------------------------------ | :------------ | | once | Unobserve the element after the first intersection event | `boolean` | `false` | | skip | Skip observing this element without affecting the group | `boolean` | `false` | | onobserve | Called when the element is first observed or when an intersection change occurs | `(entry: IntersectionObserverEntry) => void` | `undefined` | -| onintersect | Called when the element is intersecting the viewport | `(entry: IntersectionObserverEntry) => void` | `undefined` | +| onintersect | Called when the element is intersecting the viewport | `(entry: IntersectionObserverEntry) => void` | `undefined` | | onexit | Called when the element stops intersecting | `(entry: IntersectionObserverEntry) => void` | `undefined` | -#### Callbacks: `onobserve`, `onintersect`, and `onexit` - -Every primitive above exposes the same three 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`): +### SSR -- **onobserve**: called when the element is first observed, and again on every intersection change -- **onintersect**: called only when the element is intersecting the viewport (a filtered view of `onobserve`) -- **onexit**: called when the element stops intersecting (transitions out of view); not called for the initial off-screen report +All exports are SSR-safe. You do not need guards. On the server nothing is observed, so `intersecting` is `false` and `entry` is `null`. Observation starts after mount in the browser. If above-the-fold content must look correct in the server HTML, do not gate its critical rendering on `intersecting`. -```svelte no-eval - { - console.log(entry); // IntersectionObserverEntry - console.log(entry.isIntersecting); // true | false - }} - onintersect={(entry) => { - console.log(entry.isIntersecting); // always true - }} - onexit={(entry) => { - console.log(entry.isIntersecting); // always false - }} -> -
Hello world
-
-``` - -## Use Cases +## Use cases -Realistic scenarios built from the primitives above. +Concrete setups using the exports above. ### Lazy-loading images -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. +Delay the real `src` until the image is about to enter view. `rootMargin` starts the fetch a bit early so it is ready when the user scrolls to it. `once` stops observing after load. ```svelte no-eval