Skip to content

Commit e1a3caa

Browse files
authored
feat: add onexit callback to all primitives (#157)
Completes the enter/leave pair: onintersect fires on intersecting entries, onexit fires when an element transitions out of view. The initial off-screen report does not fire onexit — exit means "was in view, no longer is", tracked per element. - IntersectionObserver / MultipleIntersectionObserver: onexit prop - intersect / intersectAttachment: "exit" CustomEvent (onexit handler typed via the svelte/elements augmentation) - createIntersectionGroup: per-node onexit option - previous-state tracking resets when an observer is rebuilt (action config change, group shared-option rebuild)
1 parent cf52db0 commit e1a3caa

12 files changed

Lines changed: 264 additions & 18 deletions

README.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ yarn add svelte-intersection-observer
4949

5050
## Library
5151

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.
52+
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.
5353

5454
### `IntersectionObserver`
5555

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

145145
#### Callback props
146146

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

149149
#### `children` snippet props
150150

@@ -258,7 +258,7 @@ Called with:
258258
}
259259
```
260260

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

263263
#### `children` snippet props
264264

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

271271
### `intersect`
272272

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.
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`/`onexit` on the observed element itself.
274274

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

308308
#### Dispatched events
309309

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).
310+
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).
311311

312312
### `intersectAttachment`
313313

@@ -417,7 +417,7 @@ const group = createIntersectionGroup(() => ({
417417

418418
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.
419419

420-
`once`, `skip`, `onobserve`, and `onintersect` are the only options that make sense per element, so those are what `group.attach(...)` accepts.
420+
`once`, `skip`, `onobserve`, `onintersect`, and `onexit` are the only options that make sense per element, so those are what `group.attach(...)` accepts.
421421

422422
#### Signature
423423

@@ -447,13 +447,15 @@ Passed once per element, to `group.attach(...)`.
447447
| skip | Skip observing this element without affecting the group | `boolean` | `false` |
448448
| onobserve | Called when the element is first observed or when an intersection change occurs | `(entry: IntersectionObserverEntry) => void` | `undefined` |
449449
| onintersect | Called when the element is intersecting the viewport | `(entry: IntersectionObserverEntry) => void` | `undefined` |
450+
| onexit | Called when the element stops intersecting | `(entry: IntersectionObserverEntry) => void` | `undefined` |
450451

451-
#### Callbacks: `onobserve` and `onintersect`
452+
#### Callbacks: `onobserve`, `onintersect`, and `onexit`
452453

453-
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`):
454+
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`):
454455

455456
- **onobserve**: called when the element is first observed, and again on every intersection change
456457
- **onintersect**: called only when the element is intersecting the viewport (a filtered view of `onobserve`)
458+
- **onexit**: called when the element stops intersecting (transitions out of view); not called for the initial off-screen report
457459

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

496501
### Autoplaying video
497502

498-
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`.
503+
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`.
499504

500505
```svelte no-eval
501506
<script lang="ts">
@@ -507,9 +512,8 @@ Play a `<video>` while it's on screen and pause it once it scrolls away. Unlike
507512
<video
508513
bind:this={video}
509514
use:intersect
510-
onobserve={(e) => {
511-
e.detail.isIntersecting ? video?.play() : video?.pause();
512-
}}
515+
onintersect={() => video?.play()}
516+
onexit={() => video?.pause()}
513517
src="/clip.mp4"
514518
muted
515519
loop

src/IntersectionObserver.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* @property {boolean} [skip] Set to `true` to pause observing without disconnecting the observer or losing `entry`/`intersecting` state. Set back to `false` to resume.
1515
* @property {(entry: IntersectionObserverEntry) => void} [onobserve] Called when the element is first observed and also whenever an intersection event occurs.
1616
* @property {(entry: IntersectionObserverEntry & { isIntersecting: true }) => void} [onintersect] Called only when the observed element is intersecting the viewport.
17+
* @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.
1718
* @property {import("svelte").Snippet<[{ intersecting: boolean, entry: null | IntersectionObserverEntry, observer: null | IntersectionObserver }]>} [children]
1819
*/
1920
@@ -30,6 +31,7 @@
3031
skip = false,
3132
onobserve,
3233
onintersect,
34+
onexit,
3335
children,
3436
} = $props();
3537
@@ -49,6 +51,8 @@
4951
observer = new IntersectionObserver(
5052
(entries) => {
5153
for (const _entry of entries) {
54+
const wasIntersecting = intersecting;
55+
5256
entry = _entry;
5357
intersecting = _entry.isIntersecting;
5458
@@ -62,6 +66,12 @@
6266
);
6367
6468
if (once) observer?.unobserve(_entry.target);
69+
} else if (wasIntersecting) {
70+
onexit?.(
71+
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
72+
_entry
73+
),
74+
);
6575
}
6676
}
6777
},

src/IntersectionObserver.svelte.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ export interface IntersectionObserverProps {
7575
entry: IntersectionObserverEntry & { isIntersecting: true },
7676
) => void;
7777

78+
/**
79+
* Called when the element transitions from intersecting
80+
* to not intersecting. Not called for the initial
81+
* off-screen report.
82+
*/
83+
onexit?: (
84+
entry: IntersectionObserverEntry & { isIntersecting: false },
85+
) => void;
86+
7887
children?: Snippet<
7988
[
8089
{

src/MultipleIntersectionObserver.svelte

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* @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.
1515
* @property {(detail: { entry: IntersectionObserverEntry, target: Element }) => void} [onobserve] Called when an element is first observed and also whenever an intersection event occurs.
1616
* @property {(detail: { entry: IntersectionObserverEntry & { isIntersecting: true }, target: Element }) => void} [onintersect] Called only when an element is intersecting the viewport.
17+
* @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.
1718
* @property {import("svelte").Snippet<[{ observer: null | IntersectionObserver, elementIntersections: Map<Element | null | undefined, boolean>, elementEntries: Map<Element | null | undefined, IntersectionObserverEntry> }]>} [children]
1819
*/
1920
@@ -30,6 +31,7 @@
3031
skip = false,
3132
onobserve,
3233
onintersect,
34+
onexit,
3335
children,
3436
} = $props();
3537
@@ -50,6 +52,7 @@
5052
(entries) => {
5153
for (const _entry of entries) {
5254
const target = /** @type {Element} */ (_entry.target);
55+
const wasIntersecting = elementIntersections.get(target);
5356
5457
elementIntersections.set(target, _entry.isIntersecting);
5558
elementEntries.set(target, _entry);
@@ -65,6 +68,14 @@
6568
target,
6669
});
6770
if (once) observer?.unobserve(target);
71+
} else if (wasIntersecting) {
72+
onexit?.({
73+
entry:
74+
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
75+
_entry
76+
),
77+
target,
78+
});
6879
}
6980
}
7081

src/MultipleIntersectionObserver.svelte.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ export interface MultipleIntersectionObserverProps {
7878
target: Element;
7979
}) => void;
8080

81+
/**
82+
* Called when an element transitions from intersecting
83+
* to not intersecting. Not called for the initial
84+
* off-screen report.
85+
*/
86+
onexit?: (detail: {
87+
entry: IntersectionObserverEntry & { isIntersecting: false };
88+
target: Element;
89+
}) => void;
90+
8191
children?: Snippet<
8292
[
8393
{

src/intersect.svelte.d.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export interface IntersectActionOptions {
3939

4040
/**
4141
* Svelte action that observes the element with the Intersection Observer API.
42-
* Dispatches `observe` (on every change) and `intersect` (on entering the
43-
* viewport) `CustomEvent`s on the element — listen with `onobserve`/`onintersect`.
42+
* Dispatches `observe` (on every change), `intersect` (on entering the
43+
* viewport), and `exit` (on leaving the viewport) `CustomEvent`s on the
44+
* element — listen with `onobserve`/`onintersect`/`onexit`.
4445
*/
4546
export const intersect: Action<
4647
Element,
@@ -50,6 +51,9 @@ export const intersect: Action<
5051
onintersect?: (
5152
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: true }>,
5253
) => void;
54+
onexit?: (
55+
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: false }>,
56+
) => void;
5357
}
5458
>;
5559

@@ -67,6 +71,9 @@ declare module "svelte/elements" {
6771
onintersect?: (
6872
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: true }>,
6973
) => void;
74+
onexit?: (
75+
event: CustomEvent<IntersectionObserverEntry & { isIntersecting: false }>,
76+
) => void;
7077
}
7178
}
7279

@@ -122,6 +129,15 @@ export interface IntersectGroupNodeOptions {
122129
onintersect?: (
123130
entry: IntersectionObserverEntry & { isIntersecting: true },
124131
) => void;
132+
133+
/**
134+
* Called when the element transitions from intersecting
135+
* to not intersecting. Not called for the initial
136+
* off-screen report.
137+
*/
138+
onexit?: (
139+
entry: IntersectionObserverEntry & { isIntersecting: false },
140+
) => void;
125141
}
126142

127143
export interface IntersectionGroup {

src/intersect.svelte.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { fromAction } from "svelte/attachments";
22

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

23+
let wasIntersecting = false;
24+
2225
const createObserver = () => {
2326
if (typeof IntersectionObserver === "undefined") return null;
2427

@@ -30,7 +33,11 @@ export function intersect(node, options = {}) {
3033
if (entry.isIntersecting) {
3134
node.dispatchEvent(new CustomEvent("intersect", { detail: entry }));
3235
if (once) observer?.unobserve(node);
36+
} else if (wasIntersecting) {
37+
node.dispatchEvent(new CustomEvent("exit", { detail: entry }));
3338
}
39+
40+
wasIntersecting = entry.isIntersecting;
3441
}
3542
},
3643
{ root, rootMargin, threshold },
@@ -57,6 +64,8 @@ export function intersect(node, options = {}) {
5764
rootMargin = newOptions.rootMargin ?? "0px";
5865
threshold = newOptions.threshold ?? 0;
5966

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

121+
/** Previous intersecting state per node, used to detect enter→exit transitions. */
122+
const wasIntersecting = new Map();
123+
112124
const handleEntries = (
113125
/** @type {IntersectionObserverEntry[]} */ entries,
114126
) => {
@@ -125,7 +137,15 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
125137
),
126138
);
127139
if (nodeOptions?.once) observer?.unobserve(target);
140+
} else if (wasIntersecting.get(target)) {
141+
nodeOptions?.onexit?.(
142+
/** @type {IntersectionObserverEntry & { isIntersecting: false }} */ (
143+
entry
144+
),
145+
);
128146
}
147+
148+
wasIntersecting.set(target, entry.isIntersecting);
129149
}
130150
};
131151

@@ -154,6 +174,7 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
154174
threshold,
155175
});
156176
configKey = key;
177+
wasIntersecting.clear();
157178

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

170192
if (callbacks.size === 0) {
171193
observer?.disconnect();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script lang="ts">
2+
import IntersectionObserver from "svelte-intersection-observer";
3+
4+
let element: null | HTMLDivElement = null;
5+
let exitCount = 0;
6+
</script>
7+
8+
<p data-testid="exit-count">Exit count: {exitCount}</p>
9+
10+
<IntersectionObserver
11+
{element}
12+
onexit={() => (exitCount += 1)}
13+
>
14+
<div bind:this={element}>Hello world</div>
15+
</IntersectionObserver>

0 commit comments

Comments
 (0)