@ariefsn/svelte-sentinel
v1.0.0
Published
A lightweight Svelte 5 component that wraps IntersectionObserver for declarative viewport visibility detection.
Maintainers
Readme
Svelte Sentinel
A lightweight Svelte 5 component that wraps the browser's IntersectionObserver API, letting you declaratively respond to an element entering or leaving the viewport.
- Zero external dependencies
- Svelte 5 runes (
$props,$state,$bindable) - Full TypeScript support
- Callbacks, bindable state, and conditional snippet slots
Installation
# npm
npm install @ariefsn/svelte-sentinel
# pnpm
pnpm add @ariefsn/svelte-sentinel
# yarn
yarn add @ariefsn/svelte-sentinel
# bun
bun add @ariefsn/svelte-sentinelPeer dependency: Svelte 5 (^5.0.0)
Quick Start
<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>Usage
Basic visibility callbacks
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>Reactive state with bind:isVisible
Bind 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>Animate on scroll (one-shot with 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>Conditional content with whenVisible / whenHidden
Use 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.
Lazy loading with rootMargin
rootMargin 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>Custom scroll container with root
Observe 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>Custom wrapper tag with as
Change 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> -->Infinite scroll
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} />Props
| 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. |
TypeScript
All types are exported from the package entry point.
import type { SentinelProps } from '@ariefsn/svelte-sentinel';Developing
# 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