Skip to content
← All questions
⚡ Intermediate

How would you implement infinite scroll with Vue?

⚡ Performance🪝 Composables🧰 VueUse👁️ Watchers

Infinite scroll loads more content as the user scrolls near the bottom of the page. The standard approach uses IntersectionObserver on a sentinel element at the end of the list. When the sentinel enters the viewport, you fetch the next page.

Basic implementation

vue
<script setup lang="ts">
interface Post {
  id: number
  title: string
}

const posts = ref<Post[]>([])
const page = ref(1)
const isLoading = ref(false)
const hasMore = ref(true)
const sentinel = ref<HTMLElement | null>(null)

async function loadMore() {
  if (isLoading.value || !hasMore.value) return

  isLoading.value = true
  const newPosts = await $fetch<Post[]>('/api/posts', {
    params: { page: page.value, limit: 20 }
  })

  posts.value.push(...newPosts)
  hasMore.value = newPosts.length === 20
  page.value++
  isLoading.value = false
}

onMounted(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) loadMore()
    },
    { rootMargin: '200px' }
  )

  watchEffect(() => {
    if (sentinel.value) observer.observe(sentinel.value)
  })

  onUnmounted(() => observer.disconnect())
})

loadMore()
</script>

<template>
  <div>
    <div v-for="post in posts" :key="post.id" class="post">
      <h3>{{ post.title }}</h3>
    </div>

    <div ref="sentinel" />

    <p v-if="isLoading">Loading...</p>
    <p v-if="!hasMore">No more posts.</p>
  </div>
</template>
Open in Vue Playground

The rootMargin: '200px' triggers the load 200px before the sentinel is visible, so content appears before the user reaches the bottom.

Composable version

Extract the logic so any list can use it:

ts
// composables/useInfiniteScroll.ts
export function useInfiniteScroll<T>(
  fetchFn: (page: number) => Promise<T[]>,
  options: { pageSize?: number; rootMargin?: string } = {}
) {
  const { pageSize = 20, rootMargin = '200px' } = options

  const items = ref<T[]>([]) as Ref<T[]>
  const page = ref(1)
  const isLoading = ref(false)
  const hasMore = ref(true)
  const sentinel = ref<HTMLElement | null>(null)

  async function loadMore() {
    if (isLoading.value || !hasMore.value) return
    isLoading.value = true

    const newItems = await fetchFn(page.value)
    items.value.push(...newItems)
    hasMore.value = newItems.length === pageSize
    page.value++
    isLoading.value = false
  }

  function reset() {
    items.value = []
    page.value = 1
    hasMore.value = true
    loadMore()
  }

  onMounted(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) loadMore()
      },
      { rootMargin }
    )

    watchEffect(() => {
      if (sentinel.value) observer.observe(sentinel.value)
    })

    onUnmounted(() => observer.disconnect())
  })

  loadMore()

  return { items, isLoading, hasMore, sentinel, reset }
}
vue
<script setup>
const {
  items: posts,
  isLoading,
  hasMore,
  sentinel
} = useInfiniteScroll((page) =>
  $fetch('/api/posts', { params: { page, limit: 20 } })
)
</script>

<template>
  <div v-for="post in posts" :key="post.id">{{ post.title }}</div>
  <div ref="sentinel" />
  <p v-if="isLoading">Loading...</p>
  <p v-if="!hasMore">End of list.</p>
</template>
Open in Vue Playground

With VueUse

VueUse provides useIntersectionObserver which simplifies the observer setup:

vue
<script setup>
import { useIntersectionObserver } from '@vueuse/core'

const sentinel = (ref < HTMLElement) | (null > null)

useIntersectionObserver(
  sentinel,
  ([entry]) => {
    if (entry.isIntersecting) loadMore()
  },
  { rootMargin: '200px' }
)
</script>
Open in Vue Playground

Cursor-based pagination

For APIs that use cursors instead of page numbers:

ts
const cursor = ref<string | null>(null)

async function loadMore() {
  if (isLoading.value || !hasMore.value) return
  isLoading.value = true

  const response = await $fetch('/api/posts', {
    params: { cursor: cursor.value, limit: 20 }
  })

  posts.value.push(...response.data)
  cursor.value = response.nextCursor
  hasMore.value = !!response.nextCursor
  isLoading.value = false
}

Combining with list virtualization

For very long lists (thousands of items), infinite scroll alone causes performance issues because all loaded items stay in the DOM. Combine it with a virtualized list:

vue
<script setup>
import { useVirtualList } from '@vueuse/core'

const { items, isLoading, hasMore, sentinel } = useInfiniteScroll(fetchPosts)

const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 80
})
</script>

<template>
  <div v-bind="containerProps" style="height: 600px; overflow-y: auto">
    <div v-bind="wrapperProps">
      <div v-for="{ data, index } in list" :key="data.id" style="height: 80px">
        {{ data.title }}
      </div>
    </div>
    <div ref="sentinel" />
  </div>
</template>
Open in Vue Playground

This way you load data incrementally AND only render visible items.

Infinite scroll vs pagination

Infinite scrollPagination
UXSeamless browsingExplicit page control
Back buttonLoses scroll positionEasy to return to a page
SEOHarder (content not in initial HTML)Each page is a URL
PerformanceRisk of large DOM over timeConstant DOM size
Best forSocial feeds, image galleriesSearch results, data tables

See also: How do template refs work? · What is VueUse?

References

Released under the MIT License.