Skip to content

Conversation

pascalbaljet
Copy link
Member

@pascalbaljet pascalbaljet commented Sep 23, 2025

This PR introduces the <InfiniteScroll> component, building upon the work that @joetannenbaum started.

It depends on PR #2561.
See also: inertiajs/inertia-laravel#774

Basic usage

On the server-side, there's a new scroll prop that provides metadata about the paginated resource. It also helps with deciding whether to append or prepend the items to the existing items, based on the scroll direction (more on that later).

Inertia::render('Users', [
    'users' => Inertia::scroll(User::paginate())
]);

On the front-end, all you have to do is wrap your items in the <InfiniteScroll> component and pass the data prop, similar to the <Deferred> and <WhenVisible> components.

<template>
  <InfiniteScroll data="users">
    <div v-for="user in users.data" :key="user.id">{{ user.name }}</div>
  </InfiniteScroll>
</template>

Loading direction and query string updates

The component automatically updates the URL as you scroll through the content. So even when you've scrolled all the way to, say, page 10, and you scroll up again, it will update the page param to 9, 8, etc. If you don't want to update the URL, you can pass the preserve-url prop. The nice thing about this is that when you refresh or freshly visit ?page=8, it will also load the previous pages as you scroll up, so by default, it triggers requests in both directions. You can change this by passing the only-next or only-previous prop.

<template>  
  <InfiniteScroll data="posts" only-previous />
  
  <InfiniteScroll data="posts" only-next />
</template>

Buffer

To provide your users a smooth experience, you may pass a number of pixels to the buffer prop. When the scroll position reaches the specified number of pixels above the end of your content, the next request will be triggered.

<template>
  <InfiniteScroll data="users" :buffer="200">
    <!-- Loads 200px before reaching end -->
  </InfiniteScroll>
</template>

Manual mode

Instead of triggering the requests automatically, you may also use manual mode. In the previous and next slots, you may provide your own UI to load more content.

<template>
  <InfiniteScroll data="users" manual>
    <template #next="{ loading, fetch, hasMore }">
      <button v-if="hasMore" @click="fetch" :disabled="loading">Load More</button>
    </template>
  </InfiniteScroll>
</template>

The slot properties include:

  • loading - Whether the content is currently loading
  • loadingPrevious - Whether the previous content is loading
  • loadingNext - Whether the next content is loading
  • fetch - Function to manually trigger loading for this side
  • hasMore - Whether more content is available in this side
  • manualMode - Whether manual mode is currently active
  • autoMode - Whether automatic loading is currently active

You may also choose to trigger a number of requests automatically, and then switch to manual mode. You can do that with the manual-after prop.

<template>
 <InfiniteScroll data="users" :manual-after="5" />
</template>

Loading slot

If you don't use the previous or next slot, you may also pass a loading slot to show an indicator.

<template>
  <InfiniteScroll data="users">
    <div v-for="user in users.data" :key="user.id">{{ user.name }}</div>

    <template #loading>
      Loading more users...
    </template>
  </InfiniteScroll>
</template>

Custom element

By default, the <InfiniteScroll> component renders as a div, but you may change this by passing an as prop.

<template>
  <InfiniteScroll data="products" as="ul">
    <li v-for="product in products.data" :key="product.id">
      {{ product.name }}
    </li>
  </InfiniteScroll>
</template>

Reverse mode

Besides using this component for things like social feeds, photo grids and data tables, you may also use it for chat interfaces. There you'd typically paginate the messages in descending order.

Inertia::render('Chat', [
    'messages' => Inertia::scroll(ChatMessage::latest('id')->cursorPaginate()),
]);

On the front-end, you can pass the reverse prop, which will flip the triggers. The previous trigger will be at the bottom, and the next trigger will be at the top.

<template>
  <InfiniteScroll data="messages" reverse />
</template>

This means that scrolling up will trigger a request to the second page. It will also automatically prepend the incoming items instead of appending them. In reverse mode, it will automatically scroll to the bottom of the content on mount. You may disable this with the auto-scroll prop.

<template>
  <InfiniteScroll data="messages" reverse :auto-scroll="false" />
</template>

Scroll preservation

When loading items before the current viewport, the component automatically preserves your visual scroll position so the content doesn't jump around as new items are prepended. This happens automatically. The component calculates the height difference and adjusts the scroll position to maintain visual stability.

Custom triggers

By default, the <InfiniteScroll> component renders the start and end triggers for you. For complex templates where the default trigger placement doesn't work, you can specify the triggers and slot with a CSS query selector.

<template>
  <InfiniteScroll
    data="users"
    slot-element="#table-body"
    start-element="#table-header"
    end-element="#table-footer"
  >
    <table>
      <thead id="table-header">
        <tr><th>Name</th></tr>
      </thead>
      <tbody id="table-body">
        <tr v-for="user in users.data" :key="user.id">
          <td>{{ user.name }}</td>
        </tr>
      </tbody>
      <tfoot id="table-footer">
        <tr><td>Footer</td></tr>
      </tfoot>
    </table>
  </InfiniteScroll>
</template>

You may also use a ref.

<script setup>
  import { ref } from 'vue'
  const beforeTrigger = ref(null)
  const afterTrigger = ref(null)
</script>

<template>
  <InfiniteScroll
    data="users"
    :start-element="() => beforeTrigger.value"
    :end-element="() => afterTrigger.value"
  >
    <div ref="beforeTrigger">Before trigger</div>
    <!-- Your content -->
    <div ref="afterTrigger">After trigger</div>
  </InfiniteScroll>
</template>

Scroll container

The component automatically detects when it's wrapped in a scroll container.

<template>
  <div style="height: 400px; overflow-y: auto;">
    <InfiniteScroll data="users" />
  </div>
</template>

Programmatic access

You can get programmatic access to the component by using a ref.

<script setup>
  import { ref } from 'vue'

  const infiniteScrollRef = ref(null)

  const fetchNext = () => {
    infiniteScrollRef.value?.fetchNext()
  }
</script>

<template>
  <button @click="fetchNext">Load More</button>

  <InfiniteScroll ref="infiniteScrollRef" data="users" manual />
</template>

It exposes the following methods:

  • fetchNext() - Manually fetch the next page
  • fetchPrevious() - Manually fetch the previous page
  • hasNext() - Whether there's a next page
  • hasPrevious() - Whether there's a previous page

@pascalbaljet pascalbaljet marked this pull request as draft September 24, 2025 13:59
@pascalbaljet pascalbaljet marked this pull request as ready for review September 26, 2025 11:00
@pascalbaljet pascalbaljet merged commit 10abd2c into master Sep 26, 2025
12 checks passed
@pascalbaljet pascalbaljet deleted the infinite-scrolling branch September 26, 2025 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant