1.9 KB, no dependencies
The whole engine. Your framework adapter adds about 0.3 KB on top.
Three options in, a snapshot out. It handles paging, retries, cancellation and the scroll trigger — you write the HTML.

npm i @scrollstackjs/react # or /vue, /svelte, or /core on its ownA real, working feed. The API is free and needs no key, so you can paste this and run it right now.
import { useInfiniteScroll } from '@scrollstackjs/react'
function Feed() {
const { pages, ref, hasNextPage, isFetchingNextPage } = useInfiniteScroll({
initialPageParam: 'https://rickandmortyapi.com/api/character?page=1',
fetchPage: async ({ pageParam, signal }) => (await fetch(pageParam, { signal })).json(),
getNextPageParam: (last) => last.info.next, // null = no more pages
})
return (
<ul>
{pages
.flatMap((p) => p.results)
.map((c) => (
<li key={c.id}>{c.name}</li>
))}
{/* Scroll this into view and the next page loads. */}
{hasNextPage && <li ref={ref}>{isFetchingNextPage ? 'Loading…' : ''}</li>}
</ul>
)
}<script setup lang="ts">
import { useInfiniteScroll } from '@scrollstackjs/vue'
import { computed } from 'vue'
const { state, target } = useInfiniteScroll({
initialPageParam: 'https://rickandmortyapi.com/api/character?page=1',
fetchPage: async ({ pageParam, signal }) => (await fetch(pageParam, { signal })).json(),
getNextPageParam: (last) => last.info.next, // null = no more pages
})
const characters = computed(() => state.value.pages.flatMap((p) => p.results))
</script>
<template>
<ul>
<li v-for="c in characters" :key="c.id">{{ c.name }}</li>
<!-- Scroll this into view and the next page loads. -->
<li v-if="state.hasNextPage" :ref="target">
{{ state.isFetchingNextPage ? 'Loading…' : '' }}
</li>
</ul>
</template><script lang="ts">
import { onDestroy } from 'svelte'
import { createInfiniteScroll } from '@scrollstackjs/svelte'
const scroll = createInfiniteScroll({
initialPageParam: 'https://rickandmortyapi.com/api/character?page=1',
fetchPage: async ({ pageParam, signal }) => (await fetch(pageParam, { signal })).json(),
getNextPageParam: (last) => last.info.next, // null = no more pages
})
const { target } = scroll
onDestroy(scroll.destroy)
</script>
<ul>
{#each $scroll.pages.flatMap((p) => p.results) as c (c.id)}
<li>{c.name}</li>
{/each}
<!-- Scroll this into view and the next page loads. -->
{#if $scroll.hasNextPage}
<li use:target>{$scroll.isFetchingNextPage ? 'Loading…' : ''}</li>
{/if}
</ul>import { createInfiniteScroll } from '@scrollstackjs/core'
const scroll = createInfiniteScroll({
initialPageParam: 'https://rickandmortyapi.com/api/character?page=1',
fetchPage: async ({ pageParam, signal }) => (await fetch(pageParam, { signal })).json(),
getNextPageParam: (last) => last.info.next,
})
scroll.subscribe(() => render(scroll.getSnapshot()))
scroll.observeTarget(sentinelElement)That is the whole required API:
| Option | What it is |
|---|---|
initialPageParam | What to fetch first — a URL, a cursor, 0, whatever your API wants. |
fetchPage | How to fetch one page. Gets the param and an AbortSignal. |
getNextPageParam | Where the next page is. Return null when there are no more. |
Everything else — pages, hasNextPage, isLoading, retries, cancellation — you get back for free.