Svelte Infinite Loader designed and rebuilt specifically for use with Svelte 5
✨ Flexible
⏰ Infinite Loop Detection
📣 Control Loader State
🔎 IntersectionObserver
based
🔥 Using Runes and Snippets
🧑🔧 Demo: svelte-5-infinite.vercel.app
- Install
svelte-infinite
npm install svelte-infinite
pnpm install svelte-infinite
yarn add svelte-infinite
- Import both
InfiniteLoader
andloaderState
fromsvelte-infinite
<script lang="ts">
import { InfiniteLoader, loaderState } from "svelte-infinite"
const allItems = $state([])
const loadMore = async () => {
const res = fetch("...")
const data = await jes.json()
allItems.push(...data)
loaderState.loaded()
}
</script>
<InfiniteLoader triggerLoad={loadMore}>
{#each allItems as user (user.id)}
<div>{user.name}</div>
{/each}
</InfiniteLoader>
This is a more realistic example use-case which includes a paginated data endpoint that your triggerLoad
function should hit every time it's called to load more data. It also includes the use of some of the optional snippets to render custom markup inside the loader component.
<script lang="ts">
// +page.svelte
import { InfiniteLoader, loaderState } from "svelte-infinite"
import UserCard from "$components/UserCard.svelte"
const LOAD_LIMIT = 20
// Assume `$page.data.items` is the `+page.server.ts` server-side loaded
// and rendered initial 20 items of the list
const allItems = $state<{ id: number, body: string }[]>($page.data.items)
let pageNumber = $state(1)
// 1. This `loadMore` function is what we'll pass the InfiniteLoader component
// to its `triggerLoad` prop.
const loadMore = async () => {
try {
pageNumber += 1
const limit = String(LOAD_LIMIT)
const skip = String(LOAD_LIMIT * (pageNumber - 1))
// If there are less results on the first page (page.server loaded data)
// than the limit, don't keep trying to fetch more. We're done.
if (allItems.length < LOAD_LIMIT) {
loaderState.complete() // <--- using loaderState
return
}
const searchParams = new URLSearchParams({ limit, skip })
// Fetch an endpoint that supports server-side pagination
const dataResponse = await fetch(`/api/data?${searchParams}`)
// Ideally, like most paginated endpoints, this should return the data
// you've requested for your page, as well as the total amount of data
// available to page through
if (!dataResponse.ok) {
loaderState.error() // <--- using loaderState
// On errors, set the pageNumber back so we can retry
// that page's data on the next 'loadMore' attempt
pageNumber -= 1
return
}
const data = await dataResponse.json()
// If we've successfully received data, push it to the reactive state variable
if (data.items.length) {
allItems.push(...data.items)
}
// If there are more (or equal) number of items loaded as are totally available
// from the API, don't keep trying to fetch more. We're done.
if (allItems.length >= data.totalCount) {
loaderState.complete() // <--- using loaderState
} else {
loaderState.loaded() // <--- using loaderState
}
} catch (error) {
console.error(error)
loaderState.error() // <--- using loaderState
pageNumber -= 1
}
}
</script>
<main class="container">
<!-- 2. Here you wrap your items with the InfiniteLoader component -->
<InfiniteLoader triggerLoad={loadMore}>
{#each allItems as user (user.id)}
<UserCard {user} />
{/each}
<!-- 3. There are a few optional snippets for customizing what is shown at the bottom
of the scroller in various states, see the 'Snippets' section for more details -->
{#snippet loading()}
Loading...
{/snippet}
{#snippet error(load)}
<div>Error fetching data</div>
<button onclick={load}>Retry</button>
{/snippet}
</InfiniteLoader>
</main>
</script>
This package consists of two parts, first the InfiniteLoader
component which is a wrapper around your items. It will trigger whichever async function you've passed to the triggerLoad
prop when the user scrolls to the bottom of the list.
Second, there is also a loaderState
import which you should use to interact with the internal state of the loader. For example, if your fetch
call errored, or you've reached the maximum number of items, etc. you can communicate that to the loader. The most basic usage example can be seen in the 'Getting Started' section above. A more complex example can be seen in the 'Example' section, and of course the application in /src/routes/+page.svelte
in this repository also has a "real-world" usage example.
The loaderState
controller has 4 methods on it. You should call these at the appropriate times to control the internal state of the InfiniteLoader
.
loaderState.loaded()
- Designed to be called after a successful fetch. Will set the internal state back to
READY
so another fetch can be attempted.
- Designed to be called after a successful fetch. Will set the internal state back to
loaderState.error()
- Designed to be called after a failed fetch or any other error. This will cause the
InfiniteLoader
to render a "Retry" button by default, or theerror
snippet.
- Designed to be called after a failed fetch or any other error. This will cause the
loaderState.complete()
- Designed to be called when you've reached the end of your list and there are no more items to fetch. This will render a "No more data" string, or the
noData
snippet.
- Designed to be called when you've reached the end of your list and there are no more items to fetch. This will render a "No more data" string, or the
loaderState.reset()
- Designed to be called when you want to reset the state of the
InfiniteLoader
to its initial state, for example if there is a search input tied to your data and the user enters a new query.
- Designed to be called when you want to reset the state of the
triggerLoad: () => Promise<void>
- required- The async function to call when we should attempt to load more data to show.
intersectionOptions:
IntersectionObserverInit
= { rootMargin: "0px 0px 200px 0px" }
- optional- The options to pass to the
IntersectionObserver
instance. See MDN for more details. The defaultrootMargin
value will cause the target to intersect 200px earlier and trigger theloadMore
function before it actually intersects with the root element (window by default). This has the effect of beginning to load the next page of data before the user has actually reached the current bottom of the list, making the experience feel more smooth. - If you are using a separate scroll container (element with
overflow-y: scroll
) other than the window / viewport, then it might be necessary for you to also pass a customroot
element here.
- The options to pass to the
loopTimeout: number = 3000
- optional- Length of the cool down period (in milliseconds).
loopDetectionTimeout: number = 2000
- optional- The time in milliseconds in which the
loopMaxCalls
count must be hit in order to trigger a cool down period.
- The time in milliseconds in which the
loopMaxCalls: number = 5
- optional- The limit of
triggerLoad
executions which will trigger a cool down period, if reached within theloopDetectionTimeout
.
- The limit of
Snippets replace slots in Svelte 5, and as such are used here to customize the content shown at the bottom of the scroller in various states. The InfiniteLoader
component has 5 snippet "slots" available.
loading
- Shown while calling
triggerLoad
and waiting on a response.
- Shown while calling
noResults
- Shown when there are no more results to display and we haven't fetched any data yet (i.e. data is less than count of items to be shown on first "page").
noData
- Shown when
loaderState.complete()
is called, indicating we've fetched and displayed all available data.
- Shown when
coolingOff
- Shown when
loaderState !== "COMPLETE"
and a loop has been detected. Will disappear andloopTimeout
when the cooling off period expires.
- Shown when
error
- Shown when there is an error or
loaderState.error()
has been called. The snippet has anattemptLoad
parameter passed to it which is just the internaltriggerLoad
function, designed for a "Retry" button or similar.
- Shown when there is an error or
- Initially inspired by jonasgeiler/svelte-infinite-loading
- Open to contributions, issues, and feedback 🙏
MIT