Infinite scroll — automatically loading more results as the user scrolls near the bottom of the page — builds on the same paginated query as standard pagination, with the front-end logic doing the real work of detecting scroll position and preventing duplicate requests.
The controller
public function index(Request $request)
{
$products = Product::query()
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->search.'%'))
->paginate(10);
if ($request->ajax()) {
return response()->json([
'html' => view('products.partials.list', compact('products'))->render(),
'hasMore' => $products->hasMorePages(),
]);
}
return view('products.index', compact('products'));
}
hasMorePages() is what tells the front end whether to keep listening for further scroll events or stop — without this flag, the scroll listener would keep firing requests for pages that no longer return any new results.
Detecting when the user has scrolled near the bottom
let currentPage = 1;
let isLoading = false;
let hasMore = true;
$(window).scroll(function () {
if (isLoading || !hasMore) return;
const nearBottom = $(window).scrollTop() + $(window).height() >= $(document).height() - 300;
if (nearBottom) {
loadMoreProducts();
}
});
function loadMoreProducts() {
isLoading = true;
currentPage++;
$.get('/products', { page: currentPage }, function (response) {
$('#product-list').append(response.html);
hasMore = response.hasMore;
isLoading = false;
});
}
The 300px buffer before the actual bottom of the page gives the next batch time to load before the user physically reaches the end of the currently visible content, avoiding a moment of visibly hitting a dead-end.
Why the isLoading flag is essential, not optional
Without isLoading, a user scrolling quickly can trigger the scroll event handler many times before the first request even completes, firing multiple simultaneous requests for the same next page — this flag is what prevents both duplicate content from being appended and unnecessary server load from redundant concurrent requests.
Showing a loading indicator during the fetch
function loadMoreProducts() {
isLoading = true;
currentPage++;
$('#loading-spinner').show();
$.get('/products', { page: currentPage }, function (response) {
$('#product-list').append(response.html);
hasMore = response.hasMore;
isLoading = false;
$('#loading-spinner').hide();
if (!hasMore) {
$('#end-of-results').show();
}
});
}
Using IntersectionObserver as a more modern alternative to scroll events
const sentinel = document.getElementById('scroll-sentinel');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && hasMore) {
loadMoreProducts();
}
});
observer.observe(sentinel);
IntersectionObserver, watching a small invisible "sentinel" element placed near the bottom of the content, is a more efficient modern alternative to a scroll event listener, which fires very frequently and can introduce janky scrolling performance if the handler itself does any non-trivial work on every single fire.
Accessibility consideration: infinite scroll's real downside
Infinite scroll can make it difficult for keyboard and screen-reader users to reach page footer content, and it removes the ability to bookmark or share a link to a specific "page" of results — for content where either of these genuinely matters, standard click-through pagination (or a hybrid with a "load more" button, rather than fully automatic scrolling) is a reasonable, more accessible alternative to weigh against pure infinite scroll.