svelte-intersection-observer
v2.2.0
Published
Detects when an element enters or exits the viewport using the Intersection Observer API.
Maintainers
Readme
svelte-intersection-observer
About
svelte-intersection-observer is a zero-dependency Svelte library built on the 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).
It offers six interchangeable primitives, all backed by the same shared observer logic.
| Primitive | 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 |
See Library for the full docs on each. Try it in the Svelte REPL.
Compatibility
| Package version | Svelte version | Notes |
| :--------------- | :----------------- | :----------------------------------------- |
| 1.x | 3, 4, 5 (non-runes) | Uses export let, slots, and on: events |
| 2.x | 5 (runes mode only) | Uses $props(), snippets, and callback props |
Installation
# NPM
npm i svelte-intersection-observer
# pnpm
pnpm i svelte-intersection-observer
# Bun
bun i svelte-intersection-observer
# Yarn
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 for realistic scenarios built from these.
IntersectionObserver
Use the bind:this directive to pass an element reference to the IntersectionObserver component.
Then bind to the reactive intersecting prop to check whether the element intersects 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>once
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.
<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>children snippet
An alternative to binding to the intersecting prop is to use the children snippet, which receives intersecting, entry, and observer.
In this example, "Hello world" fades in when its containing element intersects 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>Props
| Name | Description | Type | Default value |
| :----------- | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------ |
| element | Observed element | null or HTMLElement | 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 | 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 |
| 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: 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, not something this component controls.
Callback props
Same onobserve/onintersect behavior as described in Callbacks below.
children snippet props
| Name | Type |
| :----------- | :------------------------------------------------------------------------------------------------------------------ |
| intersecting | boolean |
| entry | null or IntersectionObserverEntry |
| observer | IntersectionObserver |
MultipleIntersectionObserver
For performance, use MultipleIntersectionObserver to observe multiple elements with one shared observer instead of instantiating 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>Using with #each
MultipleIntersectionObserver also handles a dynamic, #each-driven list: give every item its own slot in an array/object instead of 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();
let itemElements = $derived(refs);
</script>
<MultipleIntersectionObserver elements={itemElements} 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>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.
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.
Props
| Name | Description | Type | Default value |
| :------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
| elements | Array of HTML elements to observe | Array<HTMLElement \| null> | [] |
| once | Unobserve elements after the first intersection event | boolean | false |
| 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 |
| elementIntersections | Map of each element to its intersection state | Map<HTMLElement \| null, boolean> | new Map() |
| elementEntries | Map of each element to its latest entry | Map<HTMLElement \| null, IntersectionObserverEntry> | new Map() |
| observer | IntersectionObserver instance | null or IntersectionObserver | null |
| skip | Pause observing all elements without losing state | boolean | false |
Callback props
Called with:
{
entry: IntersectionObserverEntry;
target: HTMLElement;
}See Callbacks for when each one fires.
children snippet props
| Name | Type |
| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| observer | IntersectionObserver |
| elementIntersections | Map<HTMLElement \| null, boolean> |
| elementEntries | Map<HTMLElement \| null, IntersectionObserverEntry> |
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.
<script lang="ts">
import { intersect } from "svelte-intersection-observer";
let actionIntersecting = $state(false);
</script>
<header class:intersecting={actionIntersecting}>
{actionIntersecting ? "Element is in view" : "Element is not in view"}
</header>
<div
use:intersect={{ once: true }}
onobserve={(e) => {
actionIntersecting = e.detail.isIntersecting;
}}
>
Hello world
</div>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 numbers 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 |
Dispatched events
Same onobserve/onintersect behavior as described in Callbacks; the action dispatches them on the element, and e.detail is the IntersectionObserverEntry.
intersectAttachment
As of Svelte 5.29, 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:
- 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
<script lang="ts">
import { intersectAttachment } from "svelte-intersection-observer";
let attachmentIntersecting = $state(false);
</script>
<header class:intersecting={attachmentIntersecting}>
{attachmentIntersecting ? "Element is in view" : "Element is not in view"}
</header>
<div
{@attach intersectAttachment(() => ({ once: true }))}
onobserve={(e) => {
attachmentIntersecting = e.detail.isIntersecting;
}}
>
Hello world
</div>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.
Options and dispatched events are identical to the intersect action above.
createIntersectionObserver
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}.
<script lang="ts">
import { createIntersectionObserver } from "svelte-intersection-observer";
const observer = createIntersectionObserver(() => ({ threshold: 0.5 }));
</script>
<div {@attach observer.attach}>
{observer.intersecting ? "In view" : "Not in view"}
</div>createIntersectionObserver takes the same options as intersectAttachment (as a function returning the options object) and reuses its underlying observer logic.
Return value
| Name | Description | Type |
| :----------- | :------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ |
| intersecting | true if the observed element is intersecting the viewport | boolean |
| entry | Observed element metadata | null or IntersectionObserverEntry |
| attach | Attachment to apply to the observed element via {@attach} | Attachment<HTMLElement> |
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.
<script lang="ts">
import { createIntersectionGroup } from "svelte-intersection-observer";
let groupItems = $state(
Array.from({ length: 5 }, (_, i) => ({ id: i, intersecting: false })),
);
const group = createIntersectionGroup();
</script>
<header>
{#each groupItems as item (item.id)}
<div class:intersecting={item.intersecting}>
Item {item.id}: {item.intersecting ? "✓" : "✗"}
</div>
{/each}
</header>
{#each groupItems as item (item.id)}
<div
class:intersecting={item.intersecting}
{@attach group.attach({
onobserve: (entry) => (item.intersecting = entry.isIntersecting),
})}
>
Item {item.id}
</div>
{/each}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:
const group = createIntersectionGroup(() => ({
root: container,
rootMargin: "0px",
threshold: 0.5,
}));once, skip, onobserve, and onintersect are the only options that make sense per element, so those are what group.attach(...) accepts.
Signature
function createIntersectionGroup(
getSharedOptions?: () => IntersectGroupSharedOptions,
): IntersectionGroup;Shared options
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 |
group.attach(options) per-node options
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 |
Callbacks: onobserve and onintersect
Every primitive above exposes the same two callbacks, called with an 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)
<IntersectionObserver
{element}
onobserve={(entry) => {
console.log(entry); // IntersectionObserverEntry
console.log(entry.isIntersecting); // true | false
}}
onintersect={(entry) => {
console.log(entry.isIntersecting); // always true
}}
>
<div bind:this={element}>Hello world</div>
</IntersectionObserver>Use Cases
Realistic scenarios built from the primitives 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.
<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=""
/>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.
<script lang="ts">
import { intersect } from "svelte-intersection-observer";
let video: HTMLVideoElement | undefined = $state();
</script>
<video
bind:this={video}
use:intersect
onobserve={(e) => {
e.detail.isIntersecting ? video?.play() : video?.pause();
}}
src="/clip.mp4"
muted
loop
></video>Tracking impressions
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.
<div
use:intersect={{ once: true, threshold: 0.5 }}
onintersect={() => analytics.track("ad_impression", { id: "banner-1" })}
>
<!-- ad content -->
</div>Infinite scroll
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.
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.
<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>The same sentinel pattern powers infinite scroll: call a loadMore() function from onintersect instead of (or alongside) updating reachedEnd.
Reveal animation on scroll
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.
<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>skip
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.
<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>List strategy
Both MultipleIntersectionObserver and createIntersectionGroup share one observer across many elements. Pick based on how you want to wire it up:
- Use
MultipleIntersectionObserverwhen you're fine wrapping the list in a component and want a ready-madeelementIntersectionsmap handed to you via thechildrensnippet. - Use
createIntersectionGroupwhen 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).
Either way, avoid giving IntersectionObserver a single shared bind:this variable inside #each; see the warning under pooled component.
IntersectionObserverEntry
Note that all properties in IntersectionObserverEntry are read-only.
interface IntersectionObserverEntry {
target: HTMLElement;
time: number;
isIntersecting: boolean;
isVisible: boolean;
intersectionRatio: number;
intersectionRect: {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
x: number;
y: number;
};
rootBounds: {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
x: number;
y: number;
};
boundingClientRect: {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
x: number;
y: number;
};
}