Skip to content

Infinite Scroll

This recipe demonstrates implementing infinite scroll pagination using Datastar's intersection observer to load more content automatically as users scroll to the bottom of the page.

What We're Building

An article feed with:

  • Automatic loading on scroll
  • Load more button as fallback
  • Loading indicator
  • End-of-results detection
  • Performance-optimized scroll detection

Complete Implementation

Blade Template

Create resources/views/articles/feed.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Articles Feed</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
    <div class="max-w-3xl mx-auto px-4 py-8">
        <h1 class="text-3xl font-bold mb-8">Articles Feed</h1>

        <div @signals([
            '_articles' => $articles->items(),
            'newArticles' => [],
            'page' => $articles->currentPage(),
            'hasMore' => $articles->hasMorePages(),
            '_loading' => false
        ])
            data-effect="if ($newArticles && $newArticles.length > 0) { $_articles = [...$_articles, ...$newArticles]; $newArticles = []; }"
            data-on:scroll__window__throttle.300ms="
                if ($hasMore && !$_loading) {
                    const scrollPosition = window.innerHeight + window.scrollY;
                    const pageHeight = document.body.offsetHeight;
                    const triggerPoint = pageHeight - 600;
                    
                    if (scrollPosition >= triggerPoint) {
                        $_loading = true;
                        $page = $page + 1;
                        @get('/articles/feed');
                    }
                }
            ">
            
            <div class="space-y-6 mb-8">
                <template data-for__key.id="article in $_articles">
                    <article class="bg-white rounded-lg shadow p-6">
                        <h2 class="text-xl font-bold mb-2" data-text="article.title"></h2>
                        <p class="text-sm text-gray-500 mb-3" data-text="article.author"></p>
                        <p class="text-gray-700 mb-4" data-text="article.excerpt"></p>
                        <a data-attr:href="'/articles/' + article.id" class="text-blue-600 hover:text-blue-800">
                            Read more →
                        </a>
                    </article>
                </template>
            </div>

            <div data-show="$_loading" class="text-center py-8">
                <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
                <p class="text-gray-500 mt-2">Loading more articles...</p>
            </div>

            <div data-show="$hasMore && !$_loading" class="text-center py-8">
                <button
                    data-on:click="$_loading = true; $page = $page + 1; @get('/articles/feed')"
                    class="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
                    Load More Articles
                </button>
            </div>

            <div data-show="!$hasMore && $_articles.length > 0" class="text-center py-8">
                <p class="text-gray-500 text-lg font-semibold">You've reached the end! 🎉</p>
            </div>
        </div>
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/ArticleController.php:

php
<?php

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    public function feed(Request $request)
    {
        if ($request->isHyper()) {
            $page = (int) signals('page', 1);
            
            $articles = Article::latest()->paginate(10, ['*'], 'page', $page);
            
            $items = $articles->items();
            
            return hyper()->signals([
                'newArticles' => is_array($items) ? $items : $items->toArray(),
                'page' => $articles->currentPage(),
                'hasMore' => $articles->hasMorePages(),
                '_loading' => false
            ]);
        }

        $articles = Article::latest()->paginate(10);
        return view('articles.feed', compact('articles'));
    }
}

Route

Inside your web.php

php
use App\Http\Controllers\ArticleController;

Route::get('/articles', [ArticleController::class, 'feed']);

How It Works

Datastar's data-on:scroll__window listens to scroll events and checks your position:

blade
<div
    data-on:scroll__window__throttle.300ms="
        if ($hasMore && !$_loading) {
            const scrollPosition = window.innerHeight + window.scrollY;
            const pageHeight = document.body.offsetHeight;
            const triggerPoint = pageHeight - 600;
            
            if (scrollPosition >= triggerPoint) {
                $_loading = true;
                $page = $page + 1;
                @get('/articles/feed');
            }
        }
    ">
</div>

Breakdown:data-on:scroll__window - Listens to window scroll events

__throttle.300ms - Only checks every 300 milliseconds (performance optimization)

scrollPosition = window.innerHeight + window.scrollY - Calculates where the bottom of your viewport is

triggerPoint = pageHeight - 600 - Sets trigger line 600px before page end

if (scrollPosition >= triggerPoint) - When you scroll within 600px of bottom, loads more