Infinite Scroll
Load paginated data near the viewport while preserving an accessible manual control.
Result
The first page renders through SSR. An intersection observer requests the next page near the end, and a visible button remains available for keyboard users and observer failures.
<script setup lang="ts">
import { api } from '#convex/api'
const sentinel = ref<HTMLElement | null>(null)
const feed = await useConvexPaginatedQuery(api.posts.list, {}, { initialNumItems: 20 })
let observer: IntersectionObserver | undefined
function loadNext() {
if (feed.hasNextPage.value && feed.status.value !== 'loading-more') {
feed.loadMore(20)
}
}
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) loadNext()
},
{ rootMargin: '300px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer?.disconnect())
</script>
<template>
<PostList :posts="feed.results.value" />
<div ref="sentinel" aria-hidden="true" />
<button
v-if="feed.hasNextPage.value"
:disabled="feed.status.value === 'loading-more'"
@click="loadNext"
>
{{ feed.status.value === 'loading-more' ? 'Loading…' : 'Load more' }}
</button>
<button v-if="feed.status.value === 'error'" @click="feed.refresh">Retry</button>
</template>Guardrails
- Disconnect the observer with the component scope.
- Check both
hasNextPageand status. - Do not auto-retry an error in an observer loop.
- Keep the manual control visible to assistive technology.
- Reset pagination when filters change unless
keepPreviousDataintentionally bridges the transition.