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