Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ yarn add svelte-intersection-observer

## Library

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.
Every primitive shares the same core options (`root`, `rootMargin`, `threshold`, `once`, `skip`) and the same `onobserve`/`onintersect`/`onexit` callbacks. Only how you plug it into your markup differs. See [Use Cases](#use-cases) for realistic scenarios built from these.

### `IntersectionObserver`

Expand Down Expand Up @@ -144,7 +144,7 @@ In this example, "Hello world" fades in when its containing element intersects t

#### Callback props

Same `onobserve`/`onintersect` behavior as described in [Callbacks](#callbacks-onobserve-and-onintersect) below.
Same `onobserve`/`onintersect`/`onexit` behavior as described in [Callbacks](#callbacks-onobserve-onintersect-and-onexit) below.

#### `children` snippet props

Expand Down Expand Up @@ -258,7 +258,7 @@ Called with:
}
```

See [Callbacks](#callbacks-onobserve-and-onintersect) for when each one fires.
See [Callbacks](#callbacks-onobserve-onintersect-and-onexit) for when each one fires.

#### `children` snippet props

Expand All @@ -270,7 +270,7 @@ See [Callbacks](#callbacks-onobserve-and-onintersect) for when each one fires.

### `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` on the observed element itself.
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.

```svelte
<script lang="ts">
Expand Down Expand Up @@ -307,7 +307,7 @@ Options passed to `use:intersect` are reactive: updating `root`, `rootMargin`, o

#### Dispatched events

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).
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).

### `intersectAttachment`

Expand Down Expand Up @@ -417,7 +417,7 @@ const group = createIntersectionGroup(() => ({

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`, and `onintersect` are the only options that make sense per element, so those are what `group.attach(...)` accepts.
`once`, `skip`, `onobserve`, `onintersect`, and `onexit` are the only options that make sense per element, so those are what `group.attach(...)` accepts.

#### Signature

Expand Down Expand Up @@ -447,13 +447,15 @@ Passed once per element, to `group.attach(...)`.
| 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` |
| onexit | Called when the element stops intersecting | `(entry: IntersectionObserverEntry) => void` | `undefined` |

#### Callbacks: `onobserve` and `onintersect`
#### Callbacks: `onobserve`, `onintersect`, and `onexit`

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`):
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`):

- **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

```svelte no-eval
<IntersectionObserver
Expand All @@ -465,6 +467,9 @@ Every primitive above exposes the same two callbacks, called with an [`Intersect
onintersect={(entry) => {
console.log(entry.isIntersecting); // always true
}}
onexit={(entry) => {
console.log(entry.isIntersecting); // always false
}}
>
<div bind:this={element}>Hello world</div>
</IntersectionObserver>
Expand Down Expand Up @@ -495,7 +500,7 @@ Delay loading an image's real `src` until it's about to scroll into view. `rootM

### Autoplaying video

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`.
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 the `onintersect`/`onexit` pair (not `onobserve`) and skip `once`.

```svelte no-eval
<script lang="ts">
Expand All @@ -507,9 +512,8 @@ Play a `<video>` while it's on screen and pause it once it scrolls away. Unlike
<video
bind:this={video}
use:intersect
onobserve={(e) => {
e.detail.isIntersecting ? video?.play() : video?.pause();
}}
onintersect={() => video?.play()}
onexit={() => video?.pause()}
src="/clip.mp4"
muted
loop
Expand Down
10 changes: 10 additions & 0 deletions src/IntersectionObserver.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @property {boolean} [skip] Set to `true` to pause observing without disconnecting the observer or losing `entry`/`intersecting` state. Set back to `false` to resume.
* @property {(entry: IntersectionObserverEntry) => void} [onobserve] Called when the element is first observed and also whenever an intersection event occurs.
* @property {(entry: IntersectionObserverEntry & { isIntersecting: true }) => void} [onintersect] Called only when the observed element is intersecting the viewport.
* @property {(entry: IntersectionObserverEntry & { isIntersecting: false }) => void} [onexit] Called when the observed element transitions from intersecting to not intersecting. Not called for the initial off-screen report.
* @property {import("svelte").Snippet<[{ intersecting: boolean, entry: null | IntersectionObserverEntry, observer: null | IntersectionObserver }]>} [children]
*/

Expand All @@ -30,6 +31,7 @@
skip = false,
onobserve,
onintersect,
onexit,
children,
} = $props();

Expand All @@ -49,6 +51,8 @@
observer = new IntersectionObserver(
(entries) => {
for (const _entry of entries) {
const wasIntersecting = intersecting;

entry = _entry;
intersecting = _entry.isIntersecting;

Expand All @@ -62,6 +66,12 @@
);

if (once) observer?.unobserve(_entry.target);
} else if (wasIntersecting) {
onexit?.(
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
_entry
),
);
}
}
},
Expand Down
9 changes: 9 additions & 0 deletions src/IntersectionObserver.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ export interface IntersectionObserverProps {
entry: IntersectionObserverEntry & { isIntersecting: true },
) => void;

/**
* Called when the element transitions from intersecting
* to not intersecting. Not called for the initial
* off-screen report.
*/
onexit?: (
entry: IntersectionObserverEntry & { isIntersecting: false },
) => void;

children?: Snippet<
[
{
Expand Down
11 changes: 11 additions & 0 deletions src/MultipleIntersectionObserver.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @property {boolean} [skip] Set to `true` to pause observing all elements without disconnecting the observer or losing `elementIntersections`/`elementEntries` state. Set back to `false` to resume.
* @property {(detail: { entry: IntersectionObserverEntry, target: Element }) => void} [onobserve] Called when an element is first observed and also whenever an intersection event occurs.
* @property {(detail: { entry: IntersectionObserverEntry & { isIntersecting: true }, target: Element }) => void} [onintersect] Called only when an element is intersecting the viewport.
* @property {(detail: { entry: IntersectionObserverEntry & { isIntersecting: false }, target: Element }) => void} [onexit] Called when an element transitions from intersecting to not intersecting. Not called for the initial off-screen report.
* @property {import("svelte").Snippet<[{ observer: null | IntersectionObserver, elementIntersections: Map<Element | null | undefined, boolean>, elementEntries: Map<Element | null | undefined, IntersectionObserverEntry> }]>} [children]
*/

Expand All @@ -30,6 +31,7 @@
skip = false,
onobserve,
onintersect,
onexit,
children,
} = $props();

Expand All @@ -50,6 +52,7 @@
(entries) => {
for (const _entry of entries) {
const target = /** @type {Element} */ (_entry.target);
const wasIntersecting = elementIntersections.get(target);

elementIntersections.set(target, _entry.isIntersecting);
elementEntries.set(target, _entry);
Expand All @@ -65,6 +68,14 @@
target,
});
if (once) observer?.unobserve(target);
} else if (wasIntersecting) {
onexit?.({
entry:
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
_entry
),
target,
});
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/MultipleIntersectionObserver.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export interface MultipleIntersectionObserverProps {
target: Element;
}) => void;

/**
* Called when an element transitions from intersecting
* to not intersecting. Not called for the initial
* off-screen report.
*/
onexit?: (detail: {
entry: IntersectionObserverEntry & { isIntersecting: false };
target: Element;
}) => void;

children?: Snippet<
[
{
Expand Down
20 changes: 18 additions & 2 deletions src/intersect.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ export interface IntersectActionOptions {

/**
* Svelte action that observes the element with the Intersection Observer API.
* Dispatches `observe` (on every change) and `intersect` (on entering the
* viewport) `CustomEvent`s on the element — listen with `onobserve`/`onintersect`.
* Dispatches `observe` (on every change), `intersect` (on entering the
* viewport), and `exit` (on leaving the viewport) `CustomEvent`s on the
* element — listen with `onobserve`/`onintersect`/`onexit`.
*/
export const intersect: Action<
Element,
Expand All @@ -50,6 +51,9 @@ export const intersect: Action<
onintersect?: (
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: true }>,
) => void;
onexit?: (
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: false }>,
) => void;
}
>;

Expand All @@ -67,6 +71,9 @@ declare module "svelte/elements" {
onintersect?: (
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: true }>,
) => void;
onexit?: (
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: false }>,
) => void;
}
}

Expand Down Expand Up @@ -122,6 +129,15 @@ export interface IntersectGroupNodeOptions {
onintersect?: (
entry: IntersectionObserverEntry & { isIntersecting: true },
) => void;

/**
* Called when the element transitions from intersecting
* to not intersecting. Not called for the initial
* off-screen report.
*/
onexit?: (
entry: IntersectionObserverEntry & { isIntersecting: false },
) => void;
}

export interface IntersectionGroup {
Expand Down
28 changes: 25 additions & 3 deletions src/intersect.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { fromAction } from "svelte/attachments";

/**
* Svelte action that observes `node` with the Intersection Observer API.
* Dispatches `observe` (on every change) and `intersect` (on entering the
* viewport) `CustomEvent`s on `node` — listen with `onobserve`/`onintersect`.
* Dispatches `observe` (on every change), `intersect` (on entering the
* viewport), and `exit` (on leaving the viewport) `CustomEvent`s on `node` —
* listen with `onobserve`/`onintersect`/`onexit`.
* @param {Element} node
* @param {import("./intersect.svelte.d.ts").IntersectActionOptions} [options]
*/
Expand All @@ -19,6 +20,8 @@ export function intersect(node, options = {}) {
/** @type {null | IntersectionObserver} */
let observer;

let wasIntersecting = false;

const createObserver = () => {
if (typeof IntersectionObserver === "undefined") return null;

Expand All @@ -30,7 +33,11 @@ export function intersect(node, options = {}) {
if (entry.isIntersecting) {
node.dispatchEvent(new CustomEvent("intersect", { detail: entry }));
if (once) observer?.unobserve(node);
} else if (wasIntersecting) {
node.dispatchEvent(new CustomEvent("exit", { detail: entry }));
}

wasIntersecting = entry.isIntersecting;
}
},
{ root, rootMargin, threshold },
Expand All @@ -57,6 +64,8 @@ export function intersect(node, options = {}) {
rootMargin = newOptions.rootMargin ?? "0px";
threshold = newOptions.threshold ?? 0;

wasIntersecting = false;

observer?.disconnect();
observer = createObserver();
if (!newSkip) observer?.observe(node);
Expand Down Expand Up @@ -92,7 +101,7 @@ export function intersectAttachment(getOptions = () => ({})) {
*
* `root`/`rootMargin`/`threshold` are shared across every node in the group
* (they configure the one underlying observer); only `once`/`skip` and the
* `onobserve`/`onintersect` callbacks are meaningful per node. Changing
* `onobserve`/`onintersect`/`onexit` callbacks are meaningful per node. Changing
* shared options rebuilds the single shared observer and re-observes every
* element in the group; elements whose `once` already fired are re-observed
* as well, so their callbacks may fire again under the new config.
Expand All @@ -109,6 +118,9 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
/** @type {Map<Element, import("./intersect.svelte.d.ts").IntersectGroupNodeOptions>} */
const callbacks = new Map();

/** Previous intersecting state per node, used to detect enter→exit transitions. */
const wasIntersecting = new Map();

const handleEntries = (
/** @type {IntersectionObserverEntry[]} */ entries,
) => {
Expand All @@ -125,7 +137,15 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
),
);
if (nodeOptions?.once) observer?.unobserve(target);
} else if (wasIntersecting.get(target)) {
nodeOptions?.onexit?.(
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
entry
),
);
}

wasIntersecting.set(target, entry.isIntersecting);
}
};

Expand Down Expand Up @@ -154,6 +174,7 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
threshold,
});
configKey = key;
wasIntersecting.clear();

for (const [el, opts] of callbacks) {
if (!opts.skip) observer.observe(el);
Expand All @@ -166,6 +187,7 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
return () => {
observer?.unobserve(node);
callbacks.delete(node);
wasIntersecting.delete(node);

if (callbacks.size === 0) {
observer?.disconnect();
Expand Down
15 changes: 15 additions & 0 deletions tests/e2e/fixtures/ExitFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";

let element: null | HTMLDivElement = null;
let exitCount = 0;
</script>

<p data-testid="exit-count">Exit count: {exitCount}</p>

<IntersectionObserver
{element}
onexit={() => (exitCount += 1)}
>
<div bind:this={element}>Hello world</div>
</IntersectionObserver>
Loading