svelte-intersection-observer wraps the 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.
Six exports, pick by how you want to wire it up:
| Kind | Export | Use it when... |
|---|---|---|
| Component | IntersectionObserver |
you want a component with a bound intersecting prop |
| Pooled component | MultipleIntersectionObserver |
you're observing many elements and want one shared observer |
| Action | intersect |
you want use: on a plain element, no extra markup |
| Attachment | intersectAttachment |
same as the action, composed via {@attach} (Svelte 5.29+) |
| Composable | createIntersectionObserver |
you want reactive state from <script>, no directive |
| Factory | createIntersectionGroup |
you're rendering a list with {@attach} and want one shared observer |
Full docs for each are under API. Try it in the Svelte REPL.
| Package version | Svelte version | Notes |
|---|---|---|
| 1.x | 3, 4, 5 (legacy/non-runes) | Uses export let, slots, and on: events |
| 2.x | ≥5.29 (legacy + runes) | Uses $props(), snippets, and callback props |
Internals use runes. A legacy (non-runes) Svelte 5 component can still consume them: bind:, callback props, snippets, and {@attach} work across that boundary. One exception is createIntersectionObserver. It exposes intersecting/entry as plain getters, with no bind: or callback prop. Legacy templates do not reactively track getter reads, so the UI only updates if the consuming component is in runes mode.
# NPM
npm i svelte-intersection-observer
# pnpm
pnpm i svelte-intersection-observer
# Bun
bun i svelte-intersection-observer
# Yarn
yarn add svelte-intersection-observerEvery export takes the same core options (root, rootMargin, threshold, once, skip) and the same onobserve/onintersect/onexit callbacks. What changes is how you attach it in markup. Use cases shows concrete setups.
All six exports expose these three callbacks. Each receives an IntersectionObserverEntry. Components pass the entry directly; the action and attachment put it on event.detail.
- onobserve: fires when the element is first observed, then on every intersection change
- onintersect: fires only while the element intersects the viewport
- onexit: fires when the element leaves view; not called for the initial off-screen report
<IntersectionObserver
{element}
onobserve={(entry) => {
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
}}
>
<div bind:this={element}>Hello world</div>
</IntersectionObserver>Pass an element with bind:this, then bind intersecting to know whether it is in the viewport.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
let element: HTMLElement | undefined = $state();
let intersecting = $state(false);
</script>
<header class:intersecting>
{intersecting ? "Element is in view" : "Element is not in view"}
</header>
<IntersectionObserver {element} bind:intersecting>
<div bind:this={element}>Hello world</div>
</IntersectionObserver>Set once to true to unobserve after the first intersection. Good for a one-shot reveal, lazy-load, or analytics impression.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
let elementOnce: HTMLElement | undefined = $state();
let intersectOnce = $state(false);
</script>
<header class:intersecting={intersectOnce}>
{intersectOnce ? "Element is in view" : "Element is not in view"}
</header>
<IntersectionObserver
once
element={elementOnce}
bind:intersecting={intersectOnce}
>
<div bind:this={elementOnce}>Hello world</div>
</IntersectionObserver>Instead of binding intersecting, you can use the children snippet. It receives intersecting, entry, and observer.
Here, "Hello world" fades in when its container enters the viewport.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
import { fade } from "svelte/transition";
let node: HTMLElement | undefined = $state();
</script>
<header></header>
<IntersectionObserver element={node}>
{#snippet children({ intersecting })}
<div bind:this={node}>
{#if intersecting}
<div transition:fade={{ delay: 200 }}>Hello world</div>
{/if}
</div>
{/snippet}
</IntersectionObserver>| Name | Description | Type | Default value |
|---|---|---|---|
| element | Observed element | null | undefined | Element |
null |
| once | Unobserve the element after the first intersection event | boolean |
false |
| intersecting | true if the observed element is intersecting the viewport |
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 numbers between 0 and 1 |
0 |
| entry | Observed element metadata | null or IntersectionObserverEntry |
null |
| observer | IntersectionObserver instance |
null or IntersectionObserver |
null |
| skip | Pause observing without losing entry/intersecting state |
boolean |
false |
Note: for threshold values above 0, the observed element needs a non-zero width and height. That comes from the Intersection Observer API, not this component.
Same onobserve/onintersect/onexit behavior as described in Callbacks above.
| Name | Type |
|---|---|
| intersecting | boolean |
| entry | null or IntersectionObserverEntry |
| observer | IntersectionObserver |
Use MultipleIntersectionObserver when you have many elements and want one shared observer instead of one per element.
<script lang="ts">
import { MultipleIntersectionObserver } from "svelte-intersection-observer";
let ref1: HTMLElement | undefined = $state();
let ref2: HTMLElement | undefined = $state();
let elements = $derived([ref1, ref2]);
</script>
<MultipleIntersectionObserver {elements}>
{#snippet children({ elementIntersections })}
<header>
<div class:intersecting={elementIntersections.get(ref1)}>
Item 1: {elementIntersections.get(ref1) ? "✓" : "✗"}
</div>
<div class:intersecting={elementIntersections.get(ref2)}>
Item 2: {elementIntersections.get(ref2) ? "✓" : "✗"}
</div>
</header>
<div bind:this={ref1}>Item 1</div>
<div bind:this={ref2}>Item 2</div>
{/snippet}
</MultipleIntersectionObserver>MultipleIntersectionObserver works with a dynamic #each list. Give every item its own slot in an array or object; do not reuse one shared variable.
<script lang="ts">
import { MultipleIntersectionObserver } from "svelte-intersection-observer";
let items = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
text: `Item ${i + 1}`,
}));
let refs: (HTMLElement | undefined)[] = $state([]);
let itemsContainer: HTMLElement | undefined = $state();
</script>
<MultipleIntersectionObserver elements={refs} root={itemsContainer}>
{#snippet children({ elementIntersections })}
<header>
{#each items as item, i (item.id)}
<div class:intersecting={elementIntersections.get(refs[i])}>
{item.text}: {elementIntersections.get(refs[i]) ? "✓" : "✗"}
</div>
{/each}
</header>
<div
bind:this={itemsContainer}
style="height: 150px; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem;"
>
{#each items as item, i (item.id)}
<div
bind:this={refs[i]}
style="height: 100px; display: flex; align-items: center; flex-shrink: 0;"
>
{item.text}
</div>
{/each}
</div>
{/snippet}
</MultipleIntersectionObserver>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 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.
| Name | Description | Type | Default value |
|---|---|---|---|
| elements | Array of elements to observe | Array<null | undefined | Element> |
[] |
| 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 numbers 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<null | undefined | Element, boolean> |
new Map() |
| elementEntries | Map of each element to its latest observer entry | Map<null | undefined | Element, IntersectionObserverEntry> |
new Map() |
| observer | Shared IntersectionObserver instance |
null or IntersectionObserver |
null |
Same onobserve/onintersect/onexit behavior as described in Callbacks above. Each callback receives the element's individual IntersectionObserverEntry.
| Name | Type |
|---|---|
| elementIntersections | Map<null | undefined | Element, boolean> |
| elementEntries | Map<null | undefined | Element, IntersectionObserverEntry> |
| observer | IntersectionObserver |
Use the intersect action to observe an element with use: and no wrapper component.
<script lang="ts">
import { intersect } from "svelte-intersection-observer";
let inView = $state(false);
</script>
<div
use:intersect
onobserve={(e) => {
inView = e.detail.isIntersecting;
}}
>
{inView ? "In view" : "Not in view"}
</div>| 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 numbers 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 |
Use intersectAttachment in Svelte 5.29+ if you prefer {@attach} over use:.
<script lang="ts">
import { intersectAttachment } from "svelte-intersection-observer";
let inView = $state(false);
</script>
<div
{@attach intersectAttachment()}
onobserve={(e) => {
inView = e.detail.isIntersecting;
}}
>
{inView ? "In view" : "Not in view"}
</div>Same options and callbacks as intersect.
Use createIntersectionObserver to create and control observation from <script> without a directive or wrapper.
<script lang="ts">
import { createIntersectionObserver } from "svelte-intersection-observer";
let target: HTMLElement | undefined = $state();
const observer = createIntersectionObserver(() => ({
element: target,
threshold: 0.5,
}));
</script>
<div bind:this={target}>
{observer.intersecting ? "Half visible" : "Less than half visible"}
</div>function createIntersectionObserver(
getOptions?: () => IntersectOptions,
): {
readonly intersecting: boolean;
readonly entry: IntersectionObserverEntry | null;
readonly observer: IntersectionObserver | null;
};Same core options as IntersectionObserver: element, root, rootMargin, threshold, once, and skip.
Same onobserve/onintersect/onexit behavior as described in Callbacks above.
Use createIntersectionGroup for one shared observer across many elements, attaching to each node with {@attach}.
<script lang="ts">
import { createIntersectionGroup } from "svelte-intersection-observer";
let sections = [
{ id: "intro", title: "Intro", visible: false },
{ id: "details", title: "Details", visible: false },
{ id: "summary", title: "Summary", visible: false },
];
let sectionRoot: HTMLElement | undefined = $state();
const group = createIntersectionGroup(() => ({
root: sectionRoot,
threshold: 0.25,
}));
</script>
<div bind:this={sectionRoot} style="height: 200px; overflow-y: auto;">
{#each sections as section (section.id)}
<section
id={section.id}
style="height: 160px;"
{@attach group.attach({
onobserve: (entry) => {
section.visible = entry.isIntersecting;
},
})}
>
<h3>{section.title}</h3>
<p>{section.visible ? "Visible" : "Not visible"}</p>
</section>
{/each}
</div>function createIntersectionGroup(
getSharedOptions?: () => IntersectGroupSharedOptions,
): IntersectionGroup;Passed once to createIntersectionGroup. Apply to every element in the group.
| 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 numbers between 0 and 1 |
0 |
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 |
| onexit | Called when the element stops intersecting | (entry: IntersectionObserverEntry) => void |
undefined |
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.
Concrete setups using the exports above.
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.
<script lang="ts">
import { intersectAttachment } from "svelte-intersection-observer";
let loaded = $state(false);
</script>
<img
{@attach intersectAttachment(() => ({ once: true, rootMargin: "200px" }))}
onintersect={() => (loaded = true)}
src={loaded ? "/photo.jpg" : "/placeholder.jpg"}
alt=""
/>Play a <video> while it is on screen, pause when it leaves. This needs a reaction on every visibility change, so use onintersect/onexit and skip once.
<script lang="ts">
import { intersect } from "svelte-intersection-observer";
let video: HTMLVideoElement | undefined = $state();
</script>
<video
bind:this={video}
use:intersect
onintersect={() => video?.play()}
onexit={() => video?.pause()}
src="/clip.mp4"
muted
loop
></video>Fire an impression the first time an element is meaningfully visible. threshold defines "visible enough." once keeps it to a single event per element.
<div
use:intersect={{ once: true, threshold: 0.5 }}
onintersect={() => analytics.track("ad_impression", { id: "banner-1" })}
>
<!-- ad content -->
</div>To detect scroll-to-end in a scrollable container, put a sentinel after the content and set root to the container. intersecting becomes true when the sentinel enters view.
Note: root must be the scrollable element itself. It needs its own overflow and fixed height, not merely an ancestor that scrolls. If root only sits inside a scrollable ancestor, the sentinel moves with root and never changes position relative to it, so it stays permanently intersecting.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
let container: HTMLElement | undefined = $state();
let sentinel: HTMLElement | undefined = $state();
let reachedEnd = $state(false);
</script>
<header class:intersecting={reachedEnd}>
{reachedEnd ? "You've reached the end" : "Keep scrolling..."}
</header>
<div bind:this={container} style="height: 200px; overflow-y: auto;">
{#each Array.from({ length: 20 }) as _, i}
<p>Paragraph {i + 1}</p>
{/each}
<IntersectionObserver
element={sentinel}
root={container}
bind:intersecting={reachedEnd}
>
<div bind:this={sentinel} style="height: 1px;"></div>
</IntersectionObserver>
</div>Same sentinel pattern for infinite scroll: call loadMore() from onintersect instead of, or as well as, updating reachedEnd.
Keep the bind:this element outside {#if intersecting}. Only gate the animated content inside. The bound element stays in the DOM before the reveal, so things like scrollIntoView() still work, and the animation can replay each time the element enters or leaves view.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
import { fly } from "svelte/transition";
let revealNode: HTMLElement | undefined = $state();
let revealIntersecting = $state(false);
</script>
<header class:intersecting={revealIntersecting}>
<p>{revealIntersecting ? "In view" : "Not in view"}</p>
<button
style="margin-top: 0.75rem;"
onclick={() => {
revealNode?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}}
>
Scroll to section
</button>
</header>
<IntersectionObserver element={revealNode} bind:intersecting={revealIntersecting}>
<div bind:this={revealNode}>
{#if revealIntersecting}
<section transition:fly={{ y: 50, duration: 300 }}>
Hello world
</section>
{/if}
</div>
</IntersectionObserver>Respect prefers-reduced-motion by gating the transition, for example with Svelte's prefersReducedMotion store from svelte/motion (≥5.7).
Set skip to true to unobserve without disconnecting the observer or clearing entry/intersecting. Handy for pausing on an off-screen carousel panel or a closed modal. Set it back to false to resume. Unlike once, this toggles freely. MultipleIntersectionObserver and the intersect action support skip too.
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";
let elementSkip: HTMLElement | undefined = $state();
let paused = $state(false);
</script>
<button onclick={() => (paused = !paused)}>
{paused ? "Resume" : "Pause"}
</button>
<IntersectionObserver element={elementSkip} skip={paused}>
{#snippet children({ intersecting })}
<div bind:this={elementSkip}>{intersecting ? "In view" : "Not in view"}</div>
{/snippet}
</IntersectionObserver>MultipleIntersectionObserver and createIntersectionGroup both share one observer across many elements. Choose by wiring style:
MultipleIntersectionObserverif you are fine wrapping the list in a component and want anelementIntersectionsmap from thechildrensnippet.createIntersectionGroupif you would rather{@attach}on each element with no wrapper, and track intersection on your own per-item objects.
Either way, do not give IntersectionObserver a single shared bind:this inside #each. See the warning under pooled component.
Fields on an IntersectionObserverEntry are read-only. The ones you reach for most are isIntersecting, intersectionRatio, and target. MDN covers the rest.