A lightweight Svelte 5 component that wraps the browser's IntersectionObserver API, letting you declaratively respond to an element entering or leaving the viewport.
$props, $state, $bindable)# npm
npm install @ariefsn/svelte-sentinel
# pnpm
pnpm add @ariefsn/svelte-sentinel
# yarn
yarn add @ariefsn/svelte-sentinel
# bun
bun add @ariefsn/svelte-sentinel
Peer dependency: Svelte 5 (^5.0.0)
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
</script>
<Sentinel onEnter={() => console.log('in view!')} onExit={() => console.log('out of view!')}>
<p>Watch me scroll!</p>
</Sentinel>
Use onEnter, onExit, or onViewChange to react to visibility changes.
Each callback receives the raw IntersectionObserverEntry for full control.
<Sentinel
onEnter={(entry) => console.log('entered', entry.intersectionRatio)}
onExit={() => console.log('exited')}
onViewChange={(isVisible) => console.log('visible:', isVisible)}
>
<div>Observed element</div>
</Sentinel>
bind:isVisibleBind the isVisible prop to read the element's visibility reactively anywhere in the parent component — no callback needed.
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
let visible = $state(false);
</script>
<header class:scrolled={!visible}>Sticky header</header>
<Sentinel bind:isVisible={visible}>
<h1>Page hero</h1>
</Sentinel>
once)Set once={true} to disconnect the observer after the element becomes visible for the first time. Perfect for entrance animations that should only play once.
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
</script>
<style>
.box { opacity: 0; transform: translateY(2rem); transition: opacity .5s, transform .5s; }
.box.visible { opacity: 1; transform: none; }
</style>
<Sentinel once>
{#snippet whenVisible()}
<div class="box visible">I animated in!</div>
{/snippet}
{#snippet whenHidden()}
<div class="box">Waiting…</div>
{/snippet}
</Sentinel>
whenVisible / whenHiddenUse the whenVisible and whenHidden snippet slots to declaratively swap content based on viewport position. No state management required in the parent.
<Sentinel>
{#snippet whenVisible()}
<span class="badge green">In view</span>
{/snippet}
{#snippet whenHidden()}
<span class="badge grey">Out of view</span>
{/snippet}
</Sentinel>
Note:
childrenis always rendered and is independent ofwhenVisible/whenHidden. Usechildrenfor static content that should always be inside the wrapper.
rootMarginrootMargin expands the detection area beyond the viewport — useful for pre-loading images or data before they scroll into view.
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
let loaded = $state(false);
</script>
<!-- Start loading 300px before the image enters the viewport -->
<Sentinel rootMargin="300px" once onEnter={() => (loaded = true)}>
{#if loaded}
<img src="/photo.jpg" alt="Lazy loaded" />
{:else}
<div class="placeholder">Loading…</div>
{/if}
</Sentinel>
rootObserve visibility inside a scrollable container instead of the browser viewport.
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
let container: HTMLElement;
</script>
<div bind:this={container} style="overflow-y: scroll; height: 400px;">
<Sentinel root={container} onEnter={() => console.log('visible inside container')}>
<div style="margin-top: 600px;">Deep content</div>
</Sentinel>
</div>
asChange the wrapper element's HTML tag to fit your semantic HTML.
<Sentinel as="section" class="hero-section" onEnter={handleEnter}>
<h1>Hero Section</h1>
</Sentinel>
<!-- Renders as: <section class="hero-section">…</section> -->
Use a small invisible sentinel at the end of a list to trigger loading the next page.
<script>
import { Sentinel } from '@ariefsn/svelte-sentinel';
let items = $state(Array.from({ length: 20 }, (_, i) => i + 1));
let loading = $state(false);
async function loadMore() {
if (loading) return;
loading = true;
await new Promise((r) => setTimeout(r, 500)); // simulate fetch
items = [...items, ...Array.from({ length: 20 }, (_, i) => items.length + i + 1)];
loading = false;
}
</script>
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>
{#if loading}
<p>Loading…</p>
{/if}
<!-- Invisible sentinel at the bottom of the list -->
<Sentinel onEnter={loadMore} />
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto-generated | id attribute on the wrapper element. |
class |
string |
undefined |
CSS class(es) for the wrapper element. |
as |
string |
'div' |
HTML tag for the wrapper element (e.g. 'section', 'li'). |
threshold |
number | number[] |
0 |
When to trigger: 0 = any pixel visible, 1 = fully visible, or an array of ratios. |
rootMargin |
string |
'0px' |
CSS margin around the root. Positive values expand, negative values shrink the detection area. |
root |
Element | Document | null |
null |
Scroll container to use instead of the browser viewport. |
once |
boolean |
false |
Disconnect the observer after the element first becomes visible. |
isVisible |
boolean |
false |
Bindable. Use bind:isVisible to reactively track visibility. |
onEnter |
(entry: IntersectionObserverEntry) => void |
— | Called when the element enters the viewport. |
onExit |
(entry: IntersectionObserverEntry) => void |
— | Called when the element exits the viewport. |
onViewChange |
(isVisible: boolean, entry: IntersectionObserverEntry) => void |
— | Called on every visibility change. |
children |
Snippet |
— | Always-rendered content inside the wrapper. |
whenVisible |
Snippet |
— | Rendered only while the element is visible. |
whenHidden |
Snippet |
— | Rendered only while the element is not visible. |
All types are exported from the package entry point.
import type { SentinelProps } from '@ariefsn/svelte-sentinel';
# Install dependencies
bun install
# Start the dev server (runs the demo app)
bun run dev
# Run tests
bun run test
# Type-check
bun run check
# Build the library
bun run prepack