# svelte-inview turns the Intersection Observer into a Svelte action with enter and leave events

> svelte-inview is a Svelte action that watches an element cross the viewport or a parent element and reports it through typed events, which covers lazy loading, infinite scroll, video play and pause and scroll direction for animations. It weighs about two kilobytes and depends on nothing but Svelte itself.

**svelte-inview/svelte-inview** — A Svelte action that monitors an element enters or leaves the viewport.🔥 

- Repository: https://github.com/svelte-inview/svelte-inview
- Stars: 773 · Forks: 25
- Language: TypeScript
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/svelte-inview-svelte-inview

## One action, a set of events, no dependencies

svelte-inview is a Svelte action that monitors an element entering or leaving the viewport, or a parent element. The README puts the performance argument plainly: it is performant and efficient because it uses the Intersection Observer API under the hood, which means the detection work happens off the main thread rather than in a scroll handler.

The package is tiny, about 2 kb, and has no external dependencies apart from Svelte itself. That constraint shapes what you get. There is no observer abstraction to configure, no scroll listener to clean up, and no store to subscribe to.

The action is applied the way Svelte actions are applied, with use:inview, and it reports what happened through events. The basic usage passes an optional options object and listens for the change event:

```html
<script lang="ts">
  import { inview } from 'svelte-inview';

  let isInView: boolean;
  const options = {};
</script>

<div
  use:inview={options}
  on:inview_change={(event) => {
    const { inView, entry, scrollDirection, observer, node} = event.detail;
    isInView = inView;
  }}
  on:inview_enter={(event) => {
    const { inView, entry, scrollDirection, observer, node} = event.detail;
    isInView = inView;
  }}
  on:inview_leave={(event) => {
    const { inView, entry, scrollDirection, observer, node} = event.detail;
    isInView = inView;
  }}
  on:inview_init={(event) => {
    const { observer, node } = event.detail;
  }}>{isInView ? 'Hey I am in the viewport' : 'Bye, Bye'}</div>
```

Four events cover the lifecycle: change for every notification, enter, leave, and init. The init event is the one that hands you the observer and the node, which is what you need if you intend to drive the observer yourself.

## The event detail carries state, the entry, the direction and the observer

Every event ships the same payload, and the shape is the reason the package is typed. The detail object contains inView, which is the boolean you actually want, entry, which is the raw IntersectionObserverEntry, scrollDirection, the observer instance and node, the element the action was applied to.

scrollDirection is the piece that goes beyond a plain visibility check. The README lists detecting the scrolling direction as a feature, and the detail exposes it in a vertical and a horizontal form, since the animation example reads scrollDirection.vertical and compares it against 'down' and 'up'.

Having entry and observer in the payload is what makes the action a wrapper rather than a black box. If you need a threshold, an intersection ratio or the bounding box, the underlying entry is right there, and the observer is available for manual control on the init event.

The types are part of the public surface, not an afterthought. The examples import ObserverEventDetails and Options from the package, and the type of the handler is written explicitly as a CustomEvent with the detail type filled in, which is what makes the payload type-safe inside a TypeScript component.

## Svelte 5 changed the event syntax but not the payload

The README has a dedicated section for Svelte 5, and the change is small but breaks old code. In Svelte 5 the same events are element attributes, and event.detail is unchanged.

```html
<script lang="ts">
  import { inview } from 'svelte-inview';

  let isInView = $state(false);
  const options = {};
</script>

<div
  use:inview={options}
  oninview_change={(event) => {
    const { inView, entry, scrollDirection, observer, node } = event.detail;
    isInView = inView;
  }}
  oninview_enter={(event) => {
    const { inView, entry, scrollDirection, observer, node } = event.detail;
    isInView = inView;
  }}
  oninview_leave={(event) => {
    const { inView, entry, scrollDirection, observer, node } = event.detail;
    isInView = inView;
  }}
  oninview_init={(event) => {
    const { observer, node } = event.detail;
  }}
>
  {isInView ? 'Hey I am in the viewport' : 'Bye, Bye'}
</div>
```

So the colon syntax on:inview_change becomes the attribute form oninview_change, and the reactivity primitive becomes $state. Everything inside the handler, including the destructuring of event.detail, is identical.

This is worth planning for, because the same codebase may contain components on Svelte 3 and 4 and components on Svelte 5 during a migration. The action itself does not change, only how the events are attached, so the port is mechanical.

## rootMargin and unobserveOnEnter are what make it a lazy loader

The lazy loading example is the one that shows the two options that matter in practice. The first is rootMargin, set to 50px, so the image is requested 50 pixels before it scrolls into view rather than at the moment it appears. The second is unobserveOnEnter set to true, because once the image has loaded you no longer need to observe that element.

```html
<script lang="ts">
  import { inview } from 'svelte-inview';
  import type { ObserverEventDetails, Options } from 'svelte-inview';

  let isInView;
  const options: Options = {
    rootMargin: '50px',
    unobserveOnEnter: true,
  };

  const handleChange = ({ detail }: CustomEvent<ObserverEventDetails>) =>
    (isInView = detail.inView);
</script>

<div use:inview="{options}" on:inview_change="{handleChange}">
  {#if isInView}
  <img src="path/to/image.jpg" />
  {:else}
  <div class="placeholder" />
  {/if}
</div>
```

The pattern is a placeholder element standing in for the image until the element is close to the viewport, which avoids the layout shift that comes with images appearing late. unobserveOnEnter then removes that element from the observer, so a long page of images does not keep dozens of entries alive.

A negative rootMargin does the opposite, and the animation example uses -50px so the effect fires slightly after the element is fully in view rather than at the first sliver of intersection. That is the general rule: the sign of the margin decides whether you want to act early or late.

## Video control and scroll direction without a scroll listener

The video example shows the shortest possible use of the action. You pass the play and pause methods into the enter and leave handlers, and the browser API does the rest:

```html
<script lang="ts">
  import { inview } from 'svelte-inview';
  import type { ObserverEventDetails } from 'svelte-inview';

  let isInView: boolean;
  let videoRef: HTMLElement;
</script>

<div
  use:inview
  on:inview_enter={() => videoRef.play()}
  on:inview_leave={() => videoRef.pause()}
>
  <video width="500" controls bind:this={videoRef}>
    <source src="path/to/video.mp4" type="video/mp4" />
  </video>
</div>
```

Note that the action is used with no options at all in that example, since the defaults are enough. The handlers ignore the event payload entirely, which is the point of having separate enter and leave events rather than a single change event with a boolean.

The animation example combines both ideas. It uses a negative rootMargin, sets unobserveOnEnter, and reads the vertical scroll direction to choose between two animation classes:

```html
  <div use:inview={options} on:inview_change={handleChange}>
    <div
      class:animate={isInView}
      class:animateFromBottom={scrollDirection === 'down'}
      class:animateFromTop={scrollDirection === 'up'}>
      Animate me!
    </div>
  </div>
```

Direction-aware animation is something the browser API gives you for free, because the entry carries the intersection rectangle, and it is the sort of detail that a hand written scroll listener usually gets wrong.

## Version 4 renamed the events and kept the old ones alive for now

The README has a section titled Important information about breaking changes in previous versions, and it is worth reading before you upgrade. Before version 4 the events were named change, leave, enter and init. In version 4 they became inview_change, inview_leave, inview_enter and inview_init.

The reason given is TypeScript. The README says the change was needed to satisfy Typescript, as the package was messing up the default types coming from svelte typings. The event names were too generic and collided with the ambient types the framework provides.

Backward compatibility is partial by design. The original event names will still work for some time, but they will not be properly recognized by Typescript. In practice that means a project still on the old names compiles with errors and runs with the events firing, which is a fine state for a migration but not a state to stay in.

The releases tell you where the package stands: 4.0.4 on 2024-10-01, 4.0.3 on 2024-09-29 and 4.0.2 on 2023-12-19, and the package metadata in the repository currently reads 4.0.5.

If you are starting a new project, use the version 4 names and the Svelte 5 attribute syntax, and you will not hit any of this.

## Playwright tests, a rollup build and browser support caveats

The repository is a small TypeScript package built with rollup. The build script is rollup -c, the module entry is dist/index.mjs, the main entry dist/index.js and the types dist/index.d.ts, with an exports map that serves types, import and require conditions.

Testing is done with Playwright rather than a unit test runner, and the tests are end to end: the test script is npx playwright test, and there is a test-app directory that the CI spins up on port 5555, with a separate script that builds the test app and then runs the tests against it. A viewport watcher is exactly the kind of component that needs a real browser to test properly, since the behaviour depends on layout and scrolling, so this is a sensible choice rather than a stylistic one.

Browser support has one caveat, and the README states it plainly. Modern browsers have full support for Intersection Observer, but if you need to support older ones such as IE, it points at the intersection-observer polyfill package, which you install and import into your project.

The licence is MIT, with the licence text in license.md at the repository root. The last push to the repository was on 2026-10-19, and the root also holds a demo/ directory with a Netlify configuration and a Vite setup, which is the same demo the README links to as a live page.

## Conclusion

svelte-inview fits Svelte projects that need viewport awareness in several places at once, where a small action with typed event payloads beats repeating Intersection Observer setup in every component. It is the wrong choice if you need only one element tracked once, since the browser API on its own is enough, and it is the wrong choice if you are on Svelte 5 and expect the older event names to be typed, because version 4 renamed them and only keeps the old ones working at runtime. Verify first that the version you install matches your Svelte major, then confirm the browser support you need, since the README recommends an Intersection Observer polyfill for older browsers such as IE, and check that rootMargin and unobserveOnEnter behave the way your lazy loading needs when images load at a different rate than expected.

## FAQ

### What does svelte-inview do?

It is a Svelte action that monitors an element entering or leaving the viewport, or a parent element, and reports it through events. It uses the Intersection Observer API under the hood, weighs about 2 kb and has no dependencies apart from Svelte.

### How do I install svelte-inview?

The README says the only thing you need is Svelte itself, and that the package is distributed via npm. The documented commands are yarn add svelte-inview or npm install --save svelte-inview.

### What events does svelte-inview fire?

Version 4 uses inview_change, inview_enter, inview_leave and inview_init, and each event carries a detail object with inView, entry, scrollDirection, observer and node. In Svelte 5 those events are element attributes, so you write oninview_change rather than on:inview_change.

### How do I lazy load images with svelte-inview?

Pass a rootMargin of 50px so the image loads shortly before it appears, and set unobserveOnEnter to true so the element stops being observed after the first entry. The README example renders a placeholder element until the inView flag turns true.

### Does svelte-inview work in older browsers?

Modern browsers have full support for Intersection Observer. If you need to support older ones such as IE, the README points at the intersection-observer polyfill package, which you install and import in your project.

## Sources

- [Issues](https://github.com/svelte-inview/svelte-inview/issues)
- [License: MIT](https://github.com/svelte-inview/svelte-inview/blob/master/LICENSE)
- [README](https://github.com/svelte-inview/svelte-inview/blob/master/README.md)
- [Releases](https://github.com/svelte-inview/svelte-inview/releases)
- [svelte-inview/svelte-inview on GitHub](https://github.com/svelte-inview/svelte-inview)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/svelte-inview-svelte-inview
