npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

svelte-intersection-observer

v2.2.0

Published

Detects when an element enters or exits the viewport using the Intersection Observer API.

Readme

svelte-intersection-observer

NPM

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 MultipleIntersectionObserver when you're fine wrapping the list in a component and want a ready-made elementIntersections map handed to you via the children snippet.
  • Use createIntersectionGroup when 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;
  };
}