@infinite-scroll-kit/svelte
v0.1.0
Published
A lightweight, headless-friendly infinite-scroll component for **Svelte 5** (runes), with SSR-safe internals and optional pull-to-refresh. Built for booking/e-commerce list and feed UIs (search results, order history, product grids) but framework-opinion-
Maintainers
Readme
@infinite-scroll-kit/svelte
A lightweight, headless-friendly infinite-scroll component for Svelte 5 (runes), with SSR-safe internals and optional pull-to-refresh. Built for booking/e-commerce list and feed UIs (search results, order history, product grids) but framework-opinion-free — bring your own markup and styling.
- Zero-cost at rest — no scroll/touch listeners are attached until the
component is both
enabledandhasMore; everything tears down on unmount. - Headless-friendly — the whole render is driven by a single
ctx(InfiniteContext) object passed to every snippet, so you can build fully custom loading/error/end-of-list UI. - Overridable defaults — the built-in loading spinner and error/retry UI are plain inline SVG with no icon-library dependency; override any of them with your own snippet.
- External scroll source aware — pass
watchScrollto subscribe to an existing scroll controller instead of attaching a redundant listener.
Install
pnpm add @infinite-scroll-kit/sveltePeer dependency: svelte@^5. No other runtime dependencies.
Quick start
<script lang="ts">
import { InfiniteScroll } from '@infinite-scroll-kit/svelte';
let items = $state([1, 2, 3]);
let hasMore = $state(true);
async function loadMore() {
const next = await fetchNextPage();
items = [...items, ...next];
if (next.length === 0) hasMore = false;
}
</script>
<div class="h-96">
<InfiniteScroll {loadMore} bind:hasMore>
{#snippet children(ctx)}
{#each items as item}<div>{item}</div>{/each}
{/snippet}
</InfiniteScroll>
</div>See examples/svelte for a runnable demo covering every feature below (run
it in a browser — see its own README for how).
Props
| Prop | Type | Default | Description |
| --------------- | --------------------- | ------- | --------------------------------------------------------|
| loadMore | () => Promise<void> | — | Required. Fetches the next page. |
| hasMore | boolean | false | Bind. True while more pages exist. |
| enabled | boolean | true | Bind. Kill switch — pauses loading without unmounting. |
| threshold | number | 0.8 | Bind. Scroll % (0–1) that triggers a load. |
| pullToRefresh | boolean | false | Enable mobile pull-to-refresh gesture. |
| onRefresh | () => Promise<void> | — | Called when a pull-to-refresh completes. |
| watchScroll | (cb) => () => void | — | External scroll subscription — see below. |
| class | string | — | Extra classes for the scroll container. |
watchScroll — integrating with an external scroll controller
If something else already manages the scroll container (e.g. a virtualized
list or a shared ScrollController), pass its subscription function as
watchScroll so InfiniteScroll skips attaching its own listener and
reacts to the existing one instead:
<InfiniteScroll {loadMore} bind:hasMore watchScroll={ctrl.watchScroll}>
{#snippet children(ctx)}
{#each items as item}<div class="border-b p-4">{item}</div>{/each}
{/snippet}
</InfiniteScroll>watchScroll's signature: (callback: (percentage: number) => void) => () => void
— called with a subscriber, returns an unsubscribe function that's invoked
automatically on destroy. When omitted, InfiniteScroll manages its own
passive scroll listener on its internal container.
Snippets
| Snippet | Receives | Description |
| ----------------------- | ------------------ | ---------------------------------- |
| children | InfiniteContext | Required. List content. |
| loaderSnippet | InfiniteContext | Custom loading indicator. |
| errorSnippet | InfiniteContext | Custom error message. |
| endMessageSnippet | InfiniteContext | Custom end-of-list message. |
| pullIndicatorSnippet | InfiniteContext | Custom pull-to-refresh indicator. |
InfiniteContext
interface InfiniteContext {
state: {
loadState: LoadState; // idle | loading | error | complete
isRefreshing: boolean;
pullDistance: number; // px, for pull-to-refresh UI
isInitialized: boolean;
scrollPercentage: number; // 0.0 – 1.0
active: boolean; // true when enabled && hasMore
};
config: {
hasMore: boolean;
enabled: boolean;
threshold: number;
pullToRefresh: boolean;
};
actions: {
initialize: () => void;
loadNext: () => Promise<void>; // also what the built-in "Try Again" button calls
refresh: () => Promise<void>;
reset: () => void;
};
}Pull-to-refresh
<InfiniteScroll {loadMore} bind:hasMore pullToRefresh onRefresh={refreshFn}>
{#snippet children(ctx)}...{/snippet}
</InfiniteScroll>Swipe down from the top on a touch device. The built-in PullIndicator
animates based on ctx.state.pullDistance; override it with the
pullIndicatorSnippet.
Reusable building blocks
Besides the components, the package exports the lower-level pieces they're built from, for anyone assembling a related but different feature:
| Export | What it is |
| ----------------------------- | ------------------------------------------------------------------ |
| calculateScrollPercentage | (element: HTMLElement) => number — clamped 0–1 scroll math. |
| easePull | (distanceInPx: number) => number — pull-to-refresh resistance curve. |
| DEFAULT_THRESHOLD | The default threshold prop value (0.8). |
| PULL_TO_REFRESH_TRIGGER_PX | Pull distance that triggers onRefresh (60). |
| PULL_EASE_EXPONENT | Exponent used by easePull (0.85). |
| LoadState, ScrollDirection| Enums used throughout the type surface. |
| InfiniteEngine | The headless engine component InfiniteScroll mounts internally — usable directly if you want your own wrapper markup. |
| LoadingSpinner, ErrorMessage, ErrorRetry, EndMessage, PullIndicator | The individual default UI pieces, usable standalone. |
All types (InfiniteState, InfiniteConfig, InfiniteActions,
InfiniteContext, InfiniteEngineProps, InfiniteScrollProps) are exported.
License
MIT
