@ariefsn/svelte-sentinel 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +285 -0
- package/dist/components/Sentinel.svelte +182 -0
- package/dist/components/Sentinel.svelte.d.ts +114 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +85 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arief Setiyo Nugroho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Svelte Sentinel
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@ariefsn/svelte-sentinel)
|
|
6
|
+
[](https://github.com/ariefsn/svelte-sentinel)
|
|
7
|
+
[](./LICENSE.md)
|
|
8
|
+
|
|
9
|
+
A lightweight **Svelte 5** component that wraps the browser's [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) API, letting you declaratively respond to an element entering or leaving the viewport.
|
|
10
|
+
|
|
11
|
+
- Zero external dependencies
|
|
12
|
+
- Svelte 5 runes (`$props`, `$state`, `$bindable`)
|
|
13
|
+
- Full TypeScript support
|
|
14
|
+
- Callbacks, bindable state, and conditional snippet slots
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# npm
|
|
22
|
+
npm install @ariefsn/svelte-sentinel
|
|
23
|
+
|
|
24
|
+
# pnpm
|
|
25
|
+
pnpm add @ariefsn/svelte-sentinel
|
|
26
|
+
|
|
27
|
+
# yarn
|
|
28
|
+
yarn add @ariefsn/svelte-sentinel
|
|
29
|
+
|
|
30
|
+
# bun
|
|
31
|
+
bun add @ariefsn/svelte-sentinel
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Peer dependency:** Svelte 5 (`^5.0.0`)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```svelte
|
|
41
|
+
<script>
|
|
42
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<Sentinel onEnter={() => console.log('in view!')} onExit={() => console.log('out of view!')}>
|
|
46
|
+
<p>Watch me scroll!</p>
|
|
47
|
+
</Sentinel>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Basic visibility callbacks
|
|
55
|
+
|
|
56
|
+
Use `onEnter`, `onExit`, or `onViewChange` to react to visibility changes.
|
|
57
|
+
Each callback receives the raw [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) for full control.
|
|
58
|
+
|
|
59
|
+
```svelte
|
|
60
|
+
<Sentinel
|
|
61
|
+
onEnter={(entry) => console.log('entered', entry.intersectionRatio)}
|
|
62
|
+
onExit={() => console.log('exited')}
|
|
63
|
+
onViewChange={(isVisible) => console.log('visible:', isVisible)}
|
|
64
|
+
>
|
|
65
|
+
<div>Observed element</div>
|
|
66
|
+
</Sentinel>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### Reactive state with `bind:isVisible`
|
|
72
|
+
|
|
73
|
+
Bind the `isVisible` prop to read the element's visibility reactively anywhere in the parent component — no callback needed.
|
|
74
|
+
|
|
75
|
+
```svelte
|
|
76
|
+
<script>
|
|
77
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
78
|
+
|
|
79
|
+
let visible = $state(false);
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<header class:scrolled={!visible}>Sticky header</header>
|
|
83
|
+
|
|
84
|
+
<Sentinel bind:isVisible={visible}>
|
|
85
|
+
<h1>Page hero</h1>
|
|
86
|
+
</Sentinel>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### Animate on scroll (one-shot with `once`)
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
```svelte
|
|
96
|
+
<script>
|
|
97
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<style>
|
|
101
|
+
.box { opacity: 0; transform: translateY(2rem); transition: opacity .5s, transform .5s; }
|
|
102
|
+
.box.visible { opacity: 1; transform: none; }
|
|
103
|
+
</style>
|
|
104
|
+
|
|
105
|
+
<Sentinel once>
|
|
106
|
+
{#snippet whenVisible()}
|
|
107
|
+
<div class="box visible">I animated in!</div>
|
|
108
|
+
{/snippet}
|
|
109
|
+
{#snippet whenHidden()}
|
|
110
|
+
<div class="box">Waiting…</div>
|
|
111
|
+
{/snippet}
|
|
112
|
+
</Sentinel>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### Conditional content with `whenVisible` / `whenHidden`
|
|
118
|
+
|
|
119
|
+
Use the `whenVisible` and `whenHidden` snippet slots to declaratively swap content based on viewport position. No state management required in the parent.
|
|
120
|
+
|
|
121
|
+
```svelte
|
|
122
|
+
<Sentinel>
|
|
123
|
+
{#snippet whenVisible()}
|
|
124
|
+
<span class="badge green">In view</span>
|
|
125
|
+
{/snippet}
|
|
126
|
+
{#snippet whenHidden()}
|
|
127
|
+
<span class="badge grey">Out of view</span>
|
|
128
|
+
{/snippet}
|
|
129
|
+
</Sentinel>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
> **Note:** `children` is always rendered and is independent of `whenVisible` / `whenHidden`. Use `children` for static content that should always be inside the wrapper.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### Lazy loading with `rootMargin`
|
|
137
|
+
|
|
138
|
+
`rootMargin` expands the detection area beyond the viewport — useful for pre-loading images or data before they scroll into view.
|
|
139
|
+
|
|
140
|
+
```svelte
|
|
141
|
+
<script>
|
|
142
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
143
|
+
let loaded = $state(false);
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<!-- Start loading 300px before the image enters the viewport -->
|
|
147
|
+
<Sentinel rootMargin="300px" once onEnter={() => (loaded = true)}>
|
|
148
|
+
{#if loaded}
|
|
149
|
+
<img src="/photo.jpg" alt="Lazy loaded" />
|
|
150
|
+
{:else}
|
|
151
|
+
<div class="placeholder">Loading…</div>
|
|
152
|
+
{/if}
|
|
153
|
+
</Sentinel>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### Custom scroll container with `root`
|
|
159
|
+
|
|
160
|
+
Observe visibility inside a scrollable container instead of the browser viewport.
|
|
161
|
+
|
|
162
|
+
```svelte
|
|
163
|
+
<script>
|
|
164
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
165
|
+
let container: HTMLElement;
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<div bind:this={container} style="overflow-y: scroll; height: 400px;">
|
|
169
|
+
<Sentinel root={container} onEnter={() => console.log('visible inside container')}>
|
|
170
|
+
<div style="margin-top: 600px;">Deep content</div>
|
|
171
|
+
</Sentinel>
|
|
172
|
+
</div>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
### Custom wrapper tag with `as`
|
|
178
|
+
|
|
179
|
+
Change the wrapper element's HTML tag to fit your semantic HTML.
|
|
180
|
+
|
|
181
|
+
```svelte
|
|
182
|
+
<Sentinel as="section" class="hero-section" onEnter={handleEnter}>
|
|
183
|
+
<h1>Hero Section</h1>
|
|
184
|
+
</Sentinel>
|
|
185
|
+
|
|
186
|
+
<!-- Renders as: <section class="hero-section">…</section> -->
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### Infinite scroll
|
|
192
|
+
|
|
193
|
+
Use a small invisible sentinel at the end of a list to trigger loading the next page.
|
|
194
|
+
|
|
195
|
+
```svelte
|
|
196
|
+
<script>
|
|
197
|
+
import { Sentinel } from '@ariefsn/svelte-sentinel';
|
|
198
|
+
|
|
199
|
+
let items = $state(Array.from({ length: 20 }, (_, i) => i + 1));
|
|
200
|
+
let loading = $state(false);
|
|
201
|
+
|
|
202
|
+
async function loadMore() {
|
|
203
|
+
if (loading) return;
|
|
204
|
+
loading = true;
|
|
205
|
+
await new Promise((r) => setTimeout(r, 500)); // simulate fetch
|
|
206
|
+
items = [...items, ...Array.from({ length: 20 }, (_, i) => items.length + i + 1)];
|
|
207
|
+
loading = false;
|
|
208
|
+
}
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<ul>
|
|
212
|
+
{#each items as item}
|
|
213
|
+
<li>{item}</li>
|
|
214
|
+
{/each}
|
|
215
|
+
</ul>
|
|
216
|
+
|
|
217
|
+
{#if loading}
|
|
218
|
+
<p>Loading…</p>
|
|
219
|
+
{/if}
|
|
220
|
+
|
|
221
|
+
<!-- Invisible sentinel at the bottom of the list -->
|
|
222
|
+
<Sentinel onEnter={loadMore} />
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Props
|
|
228
|
+
|
|
229
|
+
| Prop | Type | Default | Description |
|
|
230
|
+
|------|------|---------|-------------|
|
|
231
|
+
| `id` | `string` | auto-generated | `id` attribute on the wrapper element. |
|
|
232
|
+
| `class` | `string` | `undefined` | CSS class(es) for the wrapper element. |
|
|
233
|
+
| `as` | `string` | `'div'` | HTML tag for the wrapper element (e.g. `'section'`, `'li'`). |
|
|
234
|
+
| `threshold` | `number \| number[]` | `0` | When to trigger: `0` = any pixel visible, `1` = fully visible, or an array of ratios. |
|
|
235
|
+
| `rootMargin` | `string` | `'0px'` | CSS margin around the root. Positive values expand, negative values shrink the detection area. |
|
|
236
|
+
| `root` | `Element \| Document \| null` | `null` | Scroll container to use instead of the browser viewport. |
|
|
237
|
+
| `once` | `boolean` | `false` | Disconnect the observer after the element first becomes visible. |
|
|
238
|
+
| `isVisible` | `boolean` | `false` | Bindable. Use `bind:isVisible` to reactively track visibility. |
|
|
239
|
+
| `onEnter` | `(entry: IntersectionObserverEntry) => void` | — | Called when the element enters the viewport. |
|
|
240
|
+
| `onExit` | `(entry: IntersectionObserverEntry) => void` | — | Called when the element exits the viewport. |
|
|
241
|
+
| `onViewChange` | `(isVisible: boolean, entry: IntersectionObserverEntry) => void` | — | Called on every visibility change. |
|
|
242
|
+
| `children` | `Snippet` | — | Always-rendered content inside the wrapper. |
|
|
243
|
+
| `whenVisible` | `Snippet` | — | Rendered only while the element is visible. |
|
|
244
|
+
| `whenHidden` | `Snippet` | — | Rendered only while the element is not visible. |
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## TypeScript
|
|
249
|
+
|
|
250
|
+
All types are exported from the package entry point.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import type { SentinelProps } from '@ariefsn/svelte-sentinel';
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Developing
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Install dependencies
|
|
262
|
+
bun install
|
|
263
|
+
|
|
264
|
+
# Start the dev server (runs the demo app)
|
|
265
|
+
bun run dev
|
|
266
|
+
|
|
267
|
+
# Run tests
|
|
268
|
+
bun run test
|
|
269
|
+
|
|
270
|
+
# Type-check
|
|
271
|
+
bun run check
|
|
272
|
+
|
|
273
|
+
# Build the library
|
|
274
|
+
bun run prepack
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
[MIT](./LICENSE.md) © [ariefsn](https://github.com/ariefsn)
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
[GitHub](https://github.com/ariefsn/svelte-sentinel) · [npm](https://www.npmjs.com/package/@ariefsn/svelte-sentinel)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, type Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the Sentinel component.
|
|
6
|
+
*
|
|
7
|
+
* Sentinel wraps the browser's `IntersectionObserver` API into a reactive
|
|
8
|
+
* Svelte 5 component, letting you declaratively respond to an element
|
|
9
|
+
* entering or leaving the viewport (or a custom scroll container).
|
|
10
|
+
*/
|
|
11
|
+
export type SentinelProps = {
|
|
12
|
+
/**
|
|
13
|
+
* Unique `id` for the wrapper element.
|
|
14
|
+
* If omitted, a random ID is auto-generated.
|
|
15
|
+
*/
|
|
16
|
+
id?: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CSS class(es) to apply to the wrapper element.
|
|
20
|
+
*/
|
|
21
|
+
class?: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* HTML tag to use as the wrapper element.
|
|
25
|
+
* @default 'div'
|
|
26
|
+
* @example 'section' | 'article' | 'span' | 'li'
|
|
27
|
+
*/
|
|
28
|
+
as?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Called when the element **enters** the viewport (or root).
|
|
32
|
+
* Receives the raw `IntersectionObserverEntry` for advanced usage
|
|
33
|
+
* (e.g. reading `intersectionRatio`).
|
|
34
|
+
*/
|
|
35
|
+
onEnter?: (entry: IntersectionObserverEntry) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Called when the element **exits** the viewport (or root).
|
|
39
|
+
* Receives the raw `IntersectionObserverEntry`.
|
|
40
|
+
*/
|
|
41
|
+
onExit?: (entry: IntersectionObserverEntry) => void;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Called whenever the element's visibility changes (enter **or** exit).
|
|
45
|
+
* @param isVisible - `true` if entering, `false` if exiting.
|
|
46
|
+
* @param entry - The raw `IntersectionObserverEntry`.
|
|
47
|
+
*/
|
|
48
|
+
onViewChange?: (isVisible: boolean, entry: IntersectionObserverEntry) => void;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `IntersectionObserver` threshold(s). A number or array of numbers
|
|
52
|
+
* between `0` and `1`.
|
|
53
|
+
* - `0` — fires as soon as **any** pixel is visible (default).
|
|
54
|
+
* - `1` — fires only when the element is **fully** visible.
|
|
55
|
+
* - `[0, 0.5, 1]` — fires at 0 %, 50 %, and 100 % visibility.
|
|
56
|
+
* @default 0
|
|
57
|
+
*/
|
|
58
|
+
threshold?: number | number[];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Margin around the root element, using CSS shorthand syntax
|
|
62
|
+
* (e.g. `'100px 0px'`).
|
|
63
|
+
* - Positive values **expand** the detection area (useful for
|
|
64
|
+
* pre-loading content before it scrolls into view).
|
|
65
|
+
* - Negative values **shrink** the detection area.
|
|
66
|
+
* @default '0px'
|
|
67
|
+
*/
|
|
68
|
+
rootMargin?: string;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The element used as the intersection viewport.
|
|
72
|
+
* - Set to a scrollable container element to observe visibility
|
|
73
|
+
* within that container instead of the browser viewport.
|
|
74
|
+
* - `null` uses the browser viewport.
|
|
75
|
+
* @default null
|
|
76
|
+
*/
|
|
77
|
+
root?: Element | Document | null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* When `true`, the observer automatically disconnects after the
|
|
81
|
+
* element becomes visible for the **first time**. Ideal for
|
|
82
|
+
* one-time animations or lazy-loading.
|
|
83
|
+
* @default false
|
|
84
|
+
*/
|
|
85
|
+
once?: boolean;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reactive flag indicating whether the element is currently visible.
|
|
89
|
+
* Use `bind:isVisible` on the parent to read this value reactively
|
|
90
|
+
* without needing a callback.
|
|
91
|
+
* @default false
|
|
92
|
+
*/
|
|
93
|
+
isVisible?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Content that is **always** rendered inside the sentinel wrapper,
|
|
97
|
+
* regardless of visibility.
|
|
98
|
+
*/
|
|
99
|
+
children?: Snippet;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Content rendered **only while the element is visible**.
|
|
103
|
+
* Swaps with `whenHidden` as the element enters/exits the viewport.
|
|
104
|
+
*/
|
|
105
|
+
whenVisible?: Snippet;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Content rendered **only while the element is not visible**.
|
|
109
|
+
* Swaps with `whenVisible` as the element enters/exits the viewport.
|
|
110
|
+
*/
|
|
111
|
+
whenHidden?: Snippet;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let {
|
|
115
|
+
id,
|
|
116
|
+
class: className,
|
|
117
|
+
as: tag = 'div',
|
|
118
|
+
onEnter,
|
|
119
|
+
onExit,
|
|
120
|
+
onViewChange,
|
|
121
|
+
threshold = 0,
|
|
122
|
+
rootMargin = '0px',
|
|
123
|
+
root = null,
|
|
124
|
+
once = false,
|
|
125
|
+
isVisible = $bindable(false),
|
|
126
|
+
children,
|
|
127
|
+
whenVisible,
|
|
128
|
+
whenHidden
|
|
129
|
+
}: SentinelProps = $props();
|
|
130
|
+
|
|
131
|
+
let ref: Element | null = $state(null);
|
|
132
|
+
|
|
133
|
+
onMount(() => {
|
|
134
|
+
if (!ref) return;
|
|
135
|
+
|
|
136
|
+
const observer = new IntersectionObserver(
|
|
137
|
+
(entries) => {
|
|
138
|
+
const entry = entries[0];
|
|
139
|
+
if (!entry) return;
|
|
140
|
+
|
|
141
|
+
const visible = entry.isIntersecting;
|
|
142
|
+
isVisible = visible;
|
|
143
|
+
onViewChange?.(visible, entry);
|
|
144
|
+
|
|
145
|
+
if (visible) {
|
|
146
|
+
onEnter?.(entry);
|
|
147
|
+
if (once) observer.disconnect();
|
|
148
|
+
} else {
|
|
149
|
+
onExit?.(entry);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{ threshold, rootMargin, root }
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
observer.observe(ref);
|
|
156
|
+
return () => observer.disconnect();
|
|
157
|
+
});
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<!--
|
|
161
|
+
@component
|
|
162
|
+
**Sentinel** — a lightweight Svelte 5 wrapper around `IntersectionObserver`.
|
|
163
|
+
|
|
164
|
+
Detects when the wrapped element enters or exits the viewport and exposes
|
|
165
|
+
callbacks, a bindable `isVisible` flag, and conditional snippet slots
|
|
166
|
+
(`whenVisible` / `whenHidden`).
|
|
167
|
+
|
|
168
|
+
@example
|
|
169
|
+
```svelte
|
|
170
|
+
<Sentinel onEnter={() => console.log('visible!')} onExit={() => console.log('hidden!')}>
|
|
171
|
+
<p>Watch me scroll!</p>
|
|
172
|
+
</Sentinel>
|
|
173
|
+
```
|
|
174
|
+
-->
|
|
175
|
+
<svelte:element this={tag} {id} class={className} bind:this={ref}>
|
|
176
|
+
{@render children?.()}
|
|
177
|
+
{#if isVisible}
|
|
178
|
+
{@render whenVisible?.()}
|
|
179
|
+
{:else}
|
|
180
|
+
{@render whenHidden?.()}
|
|
181
|
+
{/if}
|
|
182
|
+
</svelte:element>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Props for the Sentinel component.
|
|
4
|
+
*
|
|
5
|
+
* Sentinel wraps the browser's `IntersectionObserver` API into a reactive
|
|
6
|
+
* Svelte 5 component, letting you declaratively respond to an element
|
|
7
|
+
* entering or leaving the viewport (or a custom scroll container).
|
|
8
|
+
*/
|
|
9
|
+
export type SentinelProps = {
|
|
10
|
+
/**
|
|
11
|
+
* Unique `id` for the wrapper element.
|
|
12
|
+
* If omitted, a random ID is auto-generated.
|
|
13
|
+
*/
|
|
14
|
+
id?: string;
|
|
15
|
+
/**
|
|
16
|
+
* CSS class(es) to apply to the wrapper element.
|
|
17
|
+
*/
|
|
18
|
+
class?: string;
|
|
19
|
+
/**
|
|
20
|
+
* HTML tag to use as the wrapper element.
|
|
21
|
+
* @default 'div'
|
|
22
|
+
* @example 'section' | 'article' | 'span' | 'li'
|
|
23
|
+
*/
|
|
24
|
+
as?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Called when the element **enters** the viewport (or root).
|
|
27
|
+
* Receives the raw `IntersectionObserverEntry` for advanced usage
|
|
28
|
+
* (e.g. reading `intersectionRatio`).
|
|
29
|
+
*/
|
|
30
|
+
onEnter?: (entry: IntersectionObserverEntry) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Called when the element **exits** the viewport (or root).
|
|
33
|
+
* Receives the raw `IntersectionObserverEntry`.
|
|
34
|
+
*/
|
|
35
|
+
onExit?: (entry: IntersectionObserverEntry) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Called whenever the element's visibility changes (enter **or** exit).
|
|
38
|
+
* @param isVisible - `true` if entering, `false` if exiting.
|
|
39
|
+
* @param entry - The raw `IntersectionObserverEntry`.
|
|
40
|
+
*/
|
|
41
|
+
onViewChange?: (isVisible: boolean, entry: IntersectionObserverEntry) => void;
|
|
42
|
+
/**
|
|
43
|
+
* `IntersectionObserver` threshold(s). A number or array of numbers
|
|
44
|
+
* between `0` and `1`.
|
|
45
|
+
* - `0` — fires as soon as **any** pixel is visible (default).
|
|
46
|
+
* - `1` — fires only when the element is **fully** visible.
|
|
47
|
+
* - `[0, 0.5, 1]` — fires at 0 %, 50 %, and 100 % visibility.
|
|
48
|
+
* @default 0
|
|
49
|
+
*/
|
|
50
|
+
threshold?: number | number[];
|
|
51
|
+
/**
|
|
52
|
+
* Margin around the root element, using CSS shorthand syntax
|
|
53
|
+
* (e.g. `'100px 0px'`).
|
|
54
|
+
* - Positive values **expand** the detection area (useful for
|
|
55
|
+
* pre-loading content before it scrolls into view).
|
|
56
|
+
* - Negative values **shrink** the detection area.
|
|
57
|
+
* @default '0px'
|
|
58
|
+
*/
|
|
59
|
+
rootMargin?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The element used as the intersection viewport.
|
|
62
|
+
* - Set to a scrollable container element to observe visibility
|
|
63
|
+
* within that container instead of the browser viewport.
|
|
64
|
+
* - `null` uses the browser viewport.
|
|
65
|
+
* @default null
|
|
66
|
+
*/
|
|
67
|
+
root?: Element | Document | null;
|
|
68
|
+
/**
|
|
69
|
+
* When `true`, the observer automatically disconnects after the
|
|
70
|
+
* element becomes visible for the **first time**. Ideal for
|
|
71
|
+
* one-time animations or lazy-loading.
|
|
72
|
+
* @default false
|
|
73
|
+
*/
|
|
74
|
+
once?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Reactive flag indicating whether the element is currently visible.
|
|
77
|
+
* Use `bind:isVisible` on the parent to read this value reactively
|
|
78
|
+
* without needing a callback.
|
|
79
|
+
* @default false
|
|
80
|
+
*/
|
|
81
|
+
isVisible?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Content that is **always** rendered inside the sentinel wrapper,
|
|
84
|
+
* regardless of visibility.
|
|
85
|
+
*/
|
|
86
|
+
children?: Snippet;
|
|
87
|
+
/**
|
|
88
|
+
* Content rendered **only while the element is visible**.
|
|
89
|
+
* Swaps with `whenHidden` as the element enters/exits the viewport.
|
|
90
|
+
*/
|
|
91
|
+
whenVisible?: Snippet;
|
|
92
|
+
/**
|
|
93
|
+
* Content rendered **only while the element is not visible**.
|
|
94
|
+
* Swaps with `whenVisible` as the element enters/exits the viewport.
|
|
95
|
+
*/
|
|
96
|
+
whenHidden?: Snippet;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* **Sentinel** — a lightweight Svelte 5 wrapper around `IntersectionObserver`.
|
|
100
|
+
*
|
|
101
|
+
* Detects when the wrapped element enters or exits the viewport and exposes
|
|
102
|
+
* callbacks, a bindable `isVisible` flag, and conditional snippet slots
|
|
103
|
+
* (`whenVisible` / `whenHidden`).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```svelte
|
|
107
|
+
* <Sentinel onEnter={() => console.log('visible!')} onExit={() => console.log('hidden!')}>
|
|
108
|
+
* <p>Watch me scroll!</p>
|
|
109
|
+
* </Sentinel>
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
declare const Sentinel: import("svelte").Component<SentinelProps, {}, "isVisible">;
|
|
113
|
+
type Sentinel = ReturnType<typeof Sentinel>;
|
|
114
|
+
export default Sentinel;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Sentinel } from './components/Sentinel.svelte';
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ariefsn/svelte-sentinel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight Svelte 5 component that wraps IntersectionObserver for declarative viewport visibility detection.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ariefsn/svelte-sentinel.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/ariefsn/svelte-sentinel#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ariefsn/svelte-sentinel/issues"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "vite dev",
|
|
17
|
+
"build": "vite build && bun run prepack",
|
|
18
|
+
"preview": "vite preview",
|
|
19
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
20
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
21
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
22
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
23
|
+
"lint": "prettier --check . && eslint .",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"test:unit": "vitest",
|
|
26
|
+
"test": "bun run test:unit -- --run"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.test.*",
|
|
31
|
+
"!dist/**/*.spec.*"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"**/*.css"
|
|
35
|
+
],
|
|
36
|
+
"svelte": "./dist/index.js",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"type": "module",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"svelte": "./dist/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"svelte": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/compat": "^2.0.2",
|
|
50
|
+
"@eslint/js": "^9.39.2",
|
|
51
|
+
"@sveltejs/adapter-auto": "^7.0.0",
|
|
52
|
+
"@sveltejs/kit": "^2.50.2",
|
|
53
|
+
"@sveltejs/package": "^2.5.7",
|
|
54
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
55
|
+
"@types/node": "^22",
|
|
56
|
+
"@vitest/browser-playwright": "^4.0.18",
|
|
57
|
+
"eslint": "^9.39.2",
|
|
58
|
+
"eslint-config-prettier": "^10.1.8",
|
|
59
|
+
"eslint-plugin-svelte": "^3.14.0",
|
|
60
|
+
"globals": "^17.3.0",
|
|
61
|
+
"playwright": "^1.58.1",
|
|
62
|
+
"prettier": "^3.8.1",
|
|
63
|
+
"prettier-plugin-svelte": "^3.4.1",
|
|
64
|
+
"publint": "^0.3.17",
|
|
65
|
+
"svelte": "^5.51.0",
|
|
66
|
+
"svelte-check": "^4.3.6",
|
|
67
|
+
"typescript": "^5.9.3",
|
|
68
|
+
"typescript-eslint": "^8.54.0",
|
|
69
|
+
"vite": "^7.3.1",
|
|
70
|
+
"vitest": "^4.0.18",
|
|
71
|
+
"vitest-browser-svelte": "^2.0.2"
|
|
72
|
+
},
|
|
73
|
+
"keywords": [
|
|
74
|
+
"svelte",
|
|
75
|
+
"svelte5",
|
|
76
|
+
"intersection-observer",
|
|
77
|
+
"visibility",
|
|
78
|
+
"viewport",
|
|
79
|
+
"scroll",
|
|
80
|
+
"lazy-load",
|
|
81
|
+
"animate-on-scroll",
|
|
82
|
+
"in-view",
|
|
83
|
+
"sentinel"
|
|
84
|
+
]
|
|
85
|
+
}
|