svelte-intersection-observer

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.

PrimitiveExportUse it when...
ComponentIntersectionObserveryou want a component with a bound intersecting prop
Pooled componentMultipleIntersectionObserveryou're observing many elements and want one shared observer
Actionintersectyou want use: on a plain element, no extra markup
AttachmentintersectAttachmentsame as the action, composed via {@attach} (Svelte 5.29+)
ComposablecreateIntersectionObserveryou want reactive state from <script>, no directive
FactorycreateIntersectionGroupyou'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 versionSvelte versionNotes
1.x3, 4, 5 (legacy/non-runes)Uses export let, slots, and on: events
2.x≥5.29 (legacy + runes)Uses $props(), snippets, and callback props

All primitives are implemented with runes internally, but Svelte 5 lets a non-runes ("legacy") component consume them — bind:, callback props, snippets, and {@attach} all interoperate across that boundary. The one exception is createIntersectionObserver: it exposes intersecting/entry as plain getters with no bind:/callback prop, and a legacy-mode template doesn't reactively track getter reads, so its state won't update the UI unless the consuming component is itself in runes mode.

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/onexit 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.

Element is not in view
Hello world
<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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";

  let element = $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.

Element is not in view
Hello world
<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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";

  let elementOnce = $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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";
  import { fade } from "svelte/transition";

  let node = $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

NameDescriptionTypeDefault value
elementObserved elementnull | undefined | Elementnull
onceUnobserve the element after the first intersection eventbooleanfalse
intersectingtrue if the observed element is intersecting the viewportbooleanfalse
rootContaining elementElement | Document | nullnull
rootMarginMargin offset of the containing elementstring"0px"
thresholdPercentage of element visibility to trigger an eventnumber between 0 and 1, or an array of numbers between 0 and 10
entryObserved element metadatanull or IntersectionObserverEntrynull
observerIntersectionObserver instancenull or IntersectionObservernull
skipPause observing without losing entry/intersecting statebooleanfalse

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/onexit behavior as described in Callbacks below.

children snippet props

NameType
intersectingboolean
entrynull or IntersectionObserverEntry
observerIntersectionObserver

MultipleIntersectionObserver

For performance, use MultipleIntersectionObserver to observe multiple elements with one shared observer instead of instantiating one per element.

Item 1: ✗
Item 2: ✗
Item 1
Item 2
<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>
<script>
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";

  let ref1 = $state();
  let ref2 = $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.

Item 1: ✗
Item 2: ✗
Item 3: ✗
Item 4: ✗
Item 5: ✗
Item 1
Item 2
Item 3
Item 4
Item 5
<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>
<script>
  import { MultipleIntersectionObserver } from "svelte-intersection-observer";

  let items = Array.from({ length: 5 }, (_, i) => ({
    id: i + 1,
    text: `Item ${i + 1}`,
  }));

  let refs = $state([]);
  let itemsContainer = $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

NameDescriptionTypeDefault value
elementsArray of elements to observeReadonlyArray<Element | null | undefined>[]
onceUnobserve elements after the first intersection eventbooleanfalse
rootContaining elementElement | Document | nullnull
rootMarginMargin offset of the containing elementstring"0px"
thresholdPercentage of element visibility to trigger an eventnumber between 0 and 1, or an array of numbers between 0 and 10
elementIntersectionsMap of each element to its intersection stateMap<HTMLElement | null, boolean>new Map()
elementEntriesMap of each element to its latest entryMap<HTMLElement | null, IntersectionObserverEntry>new Map()
observerIntersectionObserver instancenull or IntersectionObservernull
skipPause observing all elements without losing statebooleanfalse

Callback props

Called with:

{
  entry: IntersectionObserverEntry;
  target: HTMLElement;
}

See Callbacks for when each one fires.

children snippet props

NameType
observerIntersectionObserver
elementIntersectionsMap<HTMLElement | null, boolean>
elementEntriesMap<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/onexit on the observed element itself.

Element is not in view
Hello world
<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>
<script>
  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

NameDescriptionTypeDefault value
rootContaining elementnull or HTMLElementnull
rootMarginMargin offset of the containing elementstring"0px"
thresholdPercentage of element visibility to trigger an eventnumber between 0 and 1, or an array of numbers between 0 and 10
onceUnobserve the element after the first intersection eventbooleanfalse
skipPause observing without disconnecting the observerbooleanfalse

Dispatched events

Same onobserve/onintersect/onexit 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:

Element is not in view
Hello world
<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>
<script>
  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>
<script>
  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.

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 with its onobserve callback) instead.

Return value

NameDescriptionType
intersectingtrue if the observed element is intersecting the viewportboolean
entryObserved element metadatanull or IntersectionObserverEntry
attachAttachment 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.

Item 0: ✗
Item 1: ✗
Item 2: ✗
Item 3: ✗
Item 4: ✗
Item 0
Item 1
Item 2
Item 3
Item 4
<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}
<script>
  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) rather than per element:

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

function createIntersectionGroup(
  getSharedOptions?: () => IntersectGroupSharedOptions,
): IntersectionGroup;

Shared options

Passed once to createIntersectionGroup; apply to every element in the group.

NameDescriptionTypeDefault value
rootContaining elementnull or HTMLElementnull
rootMarginMargin offset of the containing elementstring"0px"
thresholdPercentage of element visibility to trigger an eventnumber between 0 and 1, or an array of numbers between 0 and 10

group.attach(options) per-node options

Passed once per element, to group.attach(...).

NameDescriptionTypeDefault value
onceUnobserve the element after the first intersection eventbooleanfalse
skipSkip observing this element without affecting the groupbooleanfalse
onobserveCalled when the element is first observed or when an intersection change occurs(entry: IntersectionObserverEntry) => voidundefined
onintersectCalled when the element is intersecting the viewport(entry: IntersectionObserverEntry) => voidundefined
onexitCalled when the element stops intersecting(entry: IntersectionObserverEntry) => voidundefined

Callbacks: onobserve, onintersect, and onexit

Every primitive above exposes the same three callbacks, called with an IntersectionObserverEntry (components pass it directly; action and attachment dispatch it as event.detail):

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

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=""
/>
<script>
  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 the onintersect/onexit pair (not onobserve) 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>
<script>
  import { intersect } from "svelte-intersection-observer";

  let video = $state();
</script>

<video
  bind:this={video}
  use:intersect
  onintersect={() => video?.play()}
  onexit={() => 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.

Keep scrolling...

Paragraph 1

Paragraph 2

Paragraph 3

Paragraph 4

Paragraph 5

Paragraph 6

Paragraph 7

Paragraph 8

Paragraph 9

Paragraph 10

Paragraph 11

Paragraph 12

Paragraph 13

Paragraph 14

Paragraph 15

Paragraph 16

Paragraph 17

Paragraph 18

Paragraph 19

Paragraph 20

<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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";

  let container = $state();
  let sentinel = $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.

Not in 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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";
  import { fly } from "svelte/transition";

  let revealNode = $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>
<script>
  import IntersectionObserver from "svelte-intersection-observer";

  let elementSkip = $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:

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.

IntersectionObserverEntry
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;
  };
}