Cursor-based infinite scroll
Sparse-load a 100k-row audit log without paginating. Pattern:
debounce on scroll, fetch the next chunk by cursor, splice into
$state.
The examples on this page run against these rows:
<script lang="ts">
import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'
type Person = {
id: number
name: string
email: string
department: string
age: number
salary: number
city: string
startDate: string
active: boolean
}
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
const people: Person[] = [
{ id: 1, name: 'Ada Lovelace', email: '[email protected]', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },
{ id: 2, name: 'Grace Hopper', email: '[email protected]', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },
{ id: 3, name: 'Linus Torvalds', email: '[email protected]', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },
{ id: 4, name: 'Radia Perlman', email: '[email protected]', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },
{ id: 5, name: 'Barbara Liskov', email: '[email protected]', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },
]
const columns: GridColumns<Person> = [
{ field: 'name', header: 'Name', width: 200 },
{ field: 'department', header: 'Department', width: 150 },
{ field: 'city', header: 'City', width: 140 },
{ field: 'age', header: 'Age', width: 90 },
{ field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },
]
</script>
<script lang="ts">
import { SvGrid, type SvGridApi } from '@svgrid/grid'
type Event = { id: string; ts: string; actor: string; action: string }
let rows = $state<Event[]>([])
let cursor = $state<string | null>(null)
let loading = $state(false)
let exhausted = $state(false)
async function loadMore() {
if (loading || exhausted) return
loading = true
try {
const r = await fetch(`/api/events?cursor=${cursor ?? ''}&limit=200`)
const { items, next } = await r.json() as { items: Event[]; next: string | null }
rows = [...rows, ...items]
cursor = next
if (!next || items.length === 0) exhausted = true
} finally {
loading = false
}
}
$effect(() => { void loadMore() })
/** Wire the wrapper's scroll container to trigger near the end. */
function onScroll(el: HTMLElement) {
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 400) loadMore()
}
</script>
<div onscroll={(e) => onScroll(e.currentTarget as HTMLElement)}
style="height: 100%; overflow: auto;">
<SvGrid data={rows} columns={columns} features={features}
showPagination={false} virtualization={true} containerHeight="100%" />
{#if loading}<p>Loading moreā¦</p>{/if}
{#if exhausted}<p>End of log.</p>{/if}
</div>
Why cursor + not page number
For append-only logs (audit, time-series), page numbers shift underneath you as new events arrive. Cursor (server-issued opaque token) is stable.
Cancellation
When the user filters mid-scroll, abort the in-flight fetch:
let controller: AbortController | null = null
async function loadMore() {
controller?.abort()
controller = new AbortController()
const r = await fetch(`...`, { signal: controller.signal })
...
}
Try it
The "server" here is a local slice, so the loading pattern is visible without a backend: hold a cursor, append the next chunk, stop when it runs dry.
<script lang="ts">
// Stand-in for the endpoint. A real one takes the cursor and returns the
// next page plus the cursor after it.
const all: Person[] = Array.from({ length: 40 }, (_, i) => {
const base = people[i % people.length]!
return { ...base, id: i + 1, name: base.name + ' #' + (i + 1) }
})
const PAGE = 10
let loaded = $state<Person[]>(all.slice(0, PAGE))
let cursor = $state(PAGE)
const done = $derived(cursor >= all.length)
function loadMore() {
if (done) return
loaded = [...loaded, ...all.slice(cursor, cursor + PAGE)]
cursor += PAGE
}
</script>
<SvGrid data={loaded} {columns} sortable />
<button type="button" onclick={loadMore} disabled={done}>
{done
? 'All ' + all.length + ' rows loaded'
: 'Load ' + PAGE + ' more (' + loaded.length + '/' + all.length + ')'}
</button>
See also
- Demo 33 (Server-side infinite scroll) - live
- Server-side data - the full pattern catalog
- Real-time / streaming - if events arrive PUSHED instead of PULLED
Related articles
- Pagination Patterns - Offset, Cursor, and Infinite Scroll - A practical breakdown of offset, cursor, and infinite-scroll pagination for SvGrid - when each pattern fits, how to wire it, and where each one breaks.
- Going AI-Native: The SvGrid MCP Server - Most AI assistants invent data grid APIs. We built an MCP server so they look up the real one instead.
- Prompts That Build a Svelte Data Grid (Claude & Cursor) - Concrete prompts for Claude and Cursor that produce working SvGrid code - plus the MCP server setup that stops AI assistants from inventing props that don't exist.