Skip to content
Brok UI

Loading…

No results

Infinite Scroll

Open source

Loads the next page of a list when the end comes near (window or a scroll container), through a Livewire method or a load event with done() and fail(). A real Load more control works without JavaScript; loading, error with Retry, the end message and a polite live region keep the layout and focus still.

Version
v1.1.0
Stability
stable
License
MIT
Related
Pagination
Issue List
Item
Scroll Area
Notification Row
Spinner

Preview

Variant
previews.components.infinite-scroll.default.blade.php Blade
{{-- Alpine mode: the list renders the rows, infinite-scroll sits after it.
     When its sentinel comes within 400px of the viewport it dispatches
     `infinite-scroll-load`; the host appends the next page and answers with
     done({ hasMore, count }). This preview answers from a fake server after
     400 ms. The Load more button works by keyboard at any time. --}}
<div
    x-data="{
        rows: [],
        total: 36,
        timers: [],
        page(from, size) {
            const events = [@js(__('Invoice paid')), @js(__('Deploy finished')), @js(__('Comment added')), @js(__('Member invited'))];
            return Array.from({ length: size }, (_, index) => ({ id: from + index, title: events[(from + index) % events.length] + ' #' + (from + index) }));
        },
        init() { this.rows = this.page(1, 12) },
        load(detail) {
            this.timers.push(setTimeout(() => {
                const next = this.page(this.rows.length + 1, Math.min(8, this.total - this.rows.length));
                this.rows.push(...next);
                detail.done({ hasMore: this.rows.length < this.total, count: next.length });
            }, 400));
        },
        destroy() { this.timers.forEach(clearTimeout) },
    }"
    class="w-full max-w-xl"
>
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <template x-for="row in rows" :key="row.id">
            <li class="flex min-h-10 items-center px-3 py-2" x-text="row.title"></li>
        </template>
    </ul>
    <x-ui.infinite-scroll :page="1" x-on:infinite-scroll-load="load($event.detail)" />
</div>
Ghost Current
Outline Current

Installation

terminal
php artisan ui:add infinite-scroll

Note

This component ships an Alpine behavior module at resources/js/ui/infinite-scroll.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './infinite-scroll.js';

Registry contract

php artisan ui:add infinite-scroll writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/infinite-scroll.blade.php
  • js resources/js/ui/infinite-scroll.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

infinite-scroll.md
# Brok UI: Infinite Scroll (`infinite-scroll`)

Loads the next page of a list when the end comes near (window or a scroll container), through a Livewire method or a load event with done() and fail(). A real Load more control works without JavaScript; loading, error with Retry, the end message and a polite live region keep the layout and focus still.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add infinite-scroll
```

## Usage

```blade
{{-- Alpine mode: the list renders the rows, infinite-scroll sits after it.
     When its sentinel comes within 400px of the viewport it dispatches
     `infinite-scroll-load`; the host appends the next page and answers with
     done({ hasMore, count }). This preview answers from a fake server after
     400 ms. The Load more button works by keyboard at any time. --}}
<div
    x-data="{
        rows: [],
        total: 36,
        timers: [],
        page(from, size) {
            const events = [@js(__('Invoice paid')), @js(__('Deploy finished')), @js(__('Comment added')), @js(__('Member invited'))];
            return Array.from({ length: size }, (_, index) => ({ id: from + index, title: events[(from + index) % events.length] + ' #' + (from + index) }));
        },
        init() { this.rows = this.page(1, 12) },
        load(detail) {
            this.timers.push(setTimeout(() => {
                const next = this.page(this.rows.length + 1, Math.min(8, this.total - this.rows.length));
                this.rows.push(...next);
                detail.done({ hasMore: this.rows.length < this.total, count: next.length });
            }, 400));
        },
        destroy() { this.timers.forEach(clearTimeout) },
    }"
    class="w-full max-w-xl"
>
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <template x-for="row in rows" :key="row.id">
            <li class="flex min-h-10 items-center px-3 py-2" x-text="row.title"></li>
        </template>
    </ul>
    <x-ui.infinite-scroll :page="1" x-on:infinite-scroll-load="load($event.detail)" />
</div>
```

## Props

- `hasMore` (bool, default `true`) — False once the last page shows: nothing more loads, the control hides and the end message shows. A Livewire render that changes it always wins; in Alpine mode done(false) sets it.
- `method` (string|null, default `null`) — Livewire mode: the method of the surrounding component that appends the next page, called as $wire.call(method). A returned { hasMore, count } or boolean is used like done(); a failed or rejected call shows the error row. Null (or no Livewire in scope): the infinite-scroll-load event.
- `href` (string|null, default `null`) — The next page URL. The control is then a link to it, so the list pages without JavaScript; with JavaScript a click loads in place instead.
- `auto` (bool, default `true`) — Load when the sentinel comes within rootMargin of the scroll root. False: only the control loads. Without IntersectionObserver only the control loads either way.
- `root` (string|null, default `null`) — A CSS selector of the scroll container that holds the list, looked up as the closest ancestor first, then anywhere in the page (for a scroll-area: [data-slot=scroll-area-viewport]). Null or no match: the viewport.
- `rootMargin` (string, default `0px 0px 400px 0px`) — How early the next page loads, as an IntersectionObserver root margin of one to four px or % lengths. An invalid value uses the default.
- `chainLimit` (int, default `5`) — How many automatic loads may follow one another while the sentinel stays within the root margin (short pages on a tall screen). After the limit the control or the next scroll loads the next page. 0 turns chaining off.
- `page` (int, default `1`) — The last page shown (data-page). The load event asks for page + 1 and counts up after each done(); a render that changes it resets the count.
- `loaded` (int|null, default `null`) — How many rows show now (data-loaded). The difference after a load gives the live region its count: Loaded 20 more. Null, and no count from done(): Loaded more.
- `variant` (ghost|outline, default `ghost`) — The look of the Load more control: ghost is a quiet text button that sits under a list, outline a bordered one. Never the primary colour: the list's main action keeps it. Unknown values use ghost.
- `label` (string|null, default `null`) — The control's text. Null: the translated "Load more".
- `loadingText` (string|null, default `null`) — The control's text while a page loads. Null: the translated "Loading more…".
- `errorText` (string|null, default `null`) — The error row when fail() gives no message or a Livewire call fails. Null: the translated "Could not load more.".
- `retryLabel` (string|null, default `null`) — The control's text after an error. Null: the translated "Retry".
- `endText` (string|null, default `null`) — The end message once hasMore is false, also announced. Null: the translated "All loaded".
- `showEnd` (bool, default `true`) — False keeps the end message for screen readers only (sr-only), still a focus target.

## Use when

- Use to orient users and help them move across pages, sections, or commands.
- A long feed or inbox (notifications, activity, search results) where people read on and rarely jump to a page number: rows append when the end of the list comes near.
- A list inside its own scroll container (a sidebar list, a scroll-area) that should fetch more rows as it scrolls, with Livewire appending the rows or plain Alpine fetching them.
- A plain Load more button that also works without JavaScript: set auto to false and href to the next page.

## Avoid when

- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- People need to reach a known position, share a page link or see how many pages there are: use pagination.
- A table that sorts, filters and pages on the server with a page size control: use data-table or livewire-data-table.
- The whole list is small enough to render at once: render it and skip the extra requests, or use show-more to fold a short tail.

## Anti-patterns

- Hiding primary wayfinding in novelty interactions

## Rules

- Use the `<brok:infinite-scroll>` tag (or `<x-ui.infinite-scroll>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/infinite-scroll
- Registry JSON (files, props, contract): https://brokui.dev/r/open/infinite-scroll.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

button-only.blade.php Blade
{{-- The button alone: `auto` off and `href` set. Without JavaScript the
     control is a link to the next page (?page=2), so the list still pages;
     with JavaScript a click loads the rows in place. --}}
<div
    x-data="{
        rows: [@js(__('Northwind Traders')), @js(__('Contoso Ltd')), @js(__('Fabrikam Inc'))],
        more: [@js(__('Tailspin Toys')), @js(__('Wide World Importers')), @js(__('Adventure Works')), @js(__('Litware')), @js(__('Proseware')), @js(__('Coho Winery'))],
        load(detail) {
            this.rows.push(...this.more.splice(0, 3));
            detail.done({ hasMore: this.more.length > 0, count: 3 });
        },
    }"
    class="w-full max-w-md"
>
    <ul role="list" aria-label="{{ __('Clients') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <template x-for="row in rows" :key="row">
            <li class="px-3 py-2" x-text="row"></li>
        </template>
    </ul>
    <x-ui.infinite-scroll :auto="false" href="?page=2" :page="1" :loaded="3" variant="outline" x-on:infinite-scroll-load="load($event.detail)" />
</div>
end.blade.php Blade
{{-- The end: has-more is false, so nothing loads, the control is hidden and
     the end message shows. --}}
<div class="w-full max-w-md">
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <li class="px-3 py-2">{{ __('Invoice paid') }}</li>
        <li class="px-3 py-2">{{ __('Deploy finished') }}</li>
    </ul>
    <x-ui.infinite-scroll :has-more="false" :end-text="__('You have seen every notification')" />
</div>
error.blade.php Blade
{{-- Error: the first request fails, so the error row shows and the control
     reads Retry; the sentinel waits. Here the retry succeeds and the last
     page arrives, so the end message takes over. --}}
<div
    x-data="{
        rows: [@js(__('Invoice paid')), @js(__('Deploy finished'))],
        attempts: 0,
        load(detail) {
            this.attempts++;
            if (this.attempts === 1) {
                detail.fail(@js(__('The server did not answer. Check your connection.')));
                return;
            }
            this.rows.push(@js(__('Comment added')), @js(__('Member invited')));
            detail.done({ hasMore: false, count: 2 });
        },
    }"
    class="w-full max-w-md"
>
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <template x-for="row in rows" :key="row">
            <li class="px-3 py-2" x-text="row"></li>
        </template>
    </ul>
    <x-ui.infinite-scroll x-on:infinite-scroll-load="load($event.detail)" />
</div>
issue-list.blade.php Blade
{{-- Inside an issue list: the list renders the rows, infinite-scroll sits
     after the list and renders the sentinel and status. Here the next pages
     are server-rendered rows kept in <template> elements (with Livewire the
     render appends them instead); the handler moves one page into the
     group's rows. The list's keyboard cursor takes the new rows at once, and
     focus stays where it was. --}}
<div
    x-data="{
        load(detail, host) {
            const page = host.querySelector('template[data-page=&quot;' + detail.page + '&quot;]');
            const rows = host.querySelector('[data-slot=issue-list-group-rows]');
            if (!page || !rows) { detail.done(false); return; }
            const count = page.content.children.length;
            rows.append(page.content.cloneNode(true));
            page.remove();
            detail.done({ hasMore: Boolean(host.querySelector('template[data-page]')), count });
        },
    }"
    class="w-full max-w-4xl"
>
    <x-ui.issue-list :label="__('Backlog')">
        <x-ui.issue-list.group id="backlog" :label="__('Backlog')" state="backlog" :count="9">
            <x-ui.issue-row id="ENG-160" key="ENG-160" href="#ENG-160" :title="__('Export time entries as CSV')" state="backlog" priority="medium" :assignee="['name' => 'Ada Lovelace']" />
            <x-ui.issue-row id="ENG-161" key="ENG-161" href="#ENG-161" :title="__('Remember the last opened project')" state="backlog" priority="low" />
            <x-ui.issue-row id="ENG-162" key="ENG-162" href="#ENG-162" :title="__('Show the invoice number in the page title')" state="backlog" priority="none" :assignee="['name' => 'Grace Hopper']" />
        </x-ui.issue-list.group>
    </x-ui.issue-list>
    <x-ui.infinite-scroll :auto="false" :page="1" :loaded="3" x-on:infinite-scroll-load="load($event.detail, $event.currentTarget.parentElement)" />

    <template data-page="2">
        <x-ui.issue-row id="ENG-163" key="ENG-163" href="#ENG-163" :title="__('Warn before a timer runs past midnight')" state="backlog" priority="high" />
        <x-ui.issue-row id="ENG-164" key="ENG-164" href="#ENG-164" :title="__('Group the audit log by day')" state="backlog" priority="low" :assignee="['name' => 'Alan Turing']" />
        <x-ui.issue-row id="ENG-165" key="ENG-165" href="#ENG-165" :title="__('Keep the filter when going back to the list')" state="backlog" priority="medium" />
    </template>
    <template data-page="3">
        <x-ui.issue-row id="ENG-166" key="ENG-166" href="#ENG-166" :title="__('Add a Dutch translation for the reports')" state="backlog" priority="none" />
        <x-ui.issue-row id="ENG-167" key="ENG-167" href="#ENG-167" :title="__('Round durations to five minutes')" state="backlog" priority="low" :assignee="['name' => 'Ada Lovelace']" />
        <x-ui.issue-row id="ENG-168" key="ENG-168" href="#ENG-168" :title="__('Archive clients without projects')" state="backlog" priority="low" />
    </template>
</div>
loading.blade.php Blade
{{-- Loading: this host never answers, so the control stays on its loading
     face (aria-disabled, a spinner that stops under reduced motion) and the
     root reports aria-busy. One request runs at a time. --}}
<div class="w-full max-w-md">
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <li class="px-3 py-2">{{ __('Invoice paid') }}</li>
        <li class="px-3 py-2">{{ __('Deploy finished') }}</li>
    </ul>
    <x-ui.infinite-scroll x-on:infinite-scroll-load="() => {}" />
</div>
long-content.blade.php Blade
{{-- Long labels in a narrow column: the control truncates to one line and
     the error and end messages wrap inside the footer. --}}
<div class="w-full max-w-60 space-y-4">
    <x-ui.infinite-scroll :auto="false" :label="__('Load the next twenty-five notifications from the archive')" x-on:infinite-scroll-load="$event.detail.fail()" :error-text="__('The archive could not be reached. Your connection may be offline, or the server is busy.')" />
    <x-ui.infinite-scroll :has-more="false" :end-text="__('Every notification from the last ninety days is loaded')" />
</div>
scroll-container.blade.php Blade
{{-- Inside its own scroll container: `root` names it (the closest ancestor
     that matches), so the next page loads as that box scrolls, not the page.
     The rows append above the footer, so nothing inside the box jumps. --}}
<div
    x-data="{
        rows: Array.from({ length: 10 }, (_, index) => index + 1),
        timers: [],
        load(detail) {
            this.timers.push(setTimeout(() => {
                const start = this.rows.length + 1;
                const next = Array.from({ length: 10 }, (_, index) => start + index);
                this.rows.push(...next);
                detail.done({ hasMore: this.rows.length < 40, count: next.length });
            }, 400));
        },
        destroy() { this.timers.forEach(clearTimeout) },
    }"
    class="w-full max-w-sm"
>
    <div data-testid="feed-scroller" tabindex="0" aria-label="{{ __('Recent messages') }}" role="region" class="h-72 overflow-y-auto rounded-lg border border-border bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
        <ul role="list" class="divide-y divide-border text-sm">
            <template x-for="row in rows" :key="row">
                <li class="px-3 py-2" x-text="@js(__('Message')) + ' ' + row"></li>
            </template>
        </ul>
        <x-ui.infinite-scroll root="[data-testid=feed-scroller]" root-margin="0px 0px 120px 0px" x-on:infinite-scroll-load="load($event.detail)" />
    </div>
</div>
short-pages.blade.php Blade
{{-- Short pages on a tall screen: each page holds two rows, so after a load
     the sentinel is still within the root margin. The next page loads
     without a scroll, up to chain-limit (5) loads in a row; after that the
     Load more button or the next scroll continues. --}}
<div
    x-data="{
        rows: [],
        total: 26,
        timers: [],
        page(from, size) {
            const events = [@js(__('Invoice paid')), @js(__('Deploy finished')), @js(__('Comment added')), @js(__('Member invited'))];
            return Array.from({ length: size }, (_, index) => ({ id: from + index, title: events[(from + index) % events.length] + ' #' + (from + index) }));
        },
        init() { this.rows = this.page(1, 2) },
        load(detail) {
            this.timers.push(setTimeout(() => {
                const next = this.page(this.rows.length + 1, Math.min(2, this.total - this.rows.length));
                this.rows.push(...next);
                detail.done({ hasMore: this.rows.length < this.total, count: next.length });
            }, 100));
        },
        destroy() { this.timers.forEach(clearTimeout) },
    }"
    class="w-full max-w-xl"
>
    <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
        <template x-for="row in rows" :key="row.id">
            <li class="flex min-h-10 items-center px-3 py-2" x-text="row.title"></li>
        </template>
    </ul>
    <x-ui.infinite-scroll :page="1" :chain-limit="5" x-on:infinite-scroll-load="load($event.detail)" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
hasMore bool true False once the last page shows: nothing more loads, the control hides and the end message shows. A Livewire render that changes it always wins; in Alpine mode done(false) sets it.
method string | null null Livewire mode: the method of the surrounding component that appends the next page, called as $wire.call(method). A returned { hasMore, count } or boolean is used like done(); a failed or rejected call shows the error row. Null (or no Livewire in scope): the infinite-scroll-load event.
href string | null null The next page URL. The control is then a link to it, so the list pages without JavaScript; with JavaScript a click loads in place instead.
auto bool true Load when the sentinel comes within rootMargin of the scroll root. False: only the control loads. Without IntersectionObserver only the control loads either way.
root string | null null A CSS selector of the scroll container that holds the list, looked up as the closest ancestor first, then anywhere in the page (for a scroll-area: [data-slot=scroll-area-viewport]). Null or no match: the viewport.
rootMargin string 0px 0px 400px 0px How early the next page loads, as an IntersectionObserver root margin of one to four px or % lengths. An invalid value uses the default.
chainLimit int 5 How many automatic loads may follow one another while the sentinel stays within the root margin (short pages on a tall screen). After the limit the control or the next scroll loads the next page. 0 turns chaining off.
page int 1 The last page shown (data-page). The load event asks for page + 1 and counts up after each done(); a render that changes it resets the count.
loaded int | null null How many rows show now (data-loaded). The difference after a load gives the live region its count: Loaded 20 more. Null, and no count from done(): Loaded more.
variant ghost | outline ghost The look of the Load more control: ghost is a quiet text button that sits under a list, outline a bordered one. Never the primary colour: the list's main action keeps it. Unknown values use ghost.
label string | null null The control's text. Null: the translated "Load more".
loadingText string | null null The control's text while a page loads. Null: the translated "Loading more…".
errorText string | null null The error row when fail() gives no message or a Livewire call fails. Null: the translated "Could not load more.".
retryLabel string | null null The control's text after an error. Null: the translated "Retry".
endText string | null null The end message once hasMore is false, also announced. Null: the translated "All loaded".
showEnd bool true False keeps the end message for screen readers only (sr-only), still a focus target.

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

infinite-scroll infinite-scroll-end infinite-scroll-error infinite-scroll-footer infinite-scroll-label infinite-scroll-sentinel infinite-scroll-spinner infinite-scroll-status

Behavior

  • Place it straight after the rows it extends. The list renders the rows (an issue-list, an item-group, a <ul>); infinite-scroll renders the sentinel, one fixed-height footer with the control, the error row and the end message, and a polite live region after them. In a Livewire component keep it inside the component root, next to the list.
  • Livewire: <x-ui.infinite-scroll method="loadMore" :has-more="$hasMore" :page="$page" :loaded="count($rows)" />. The method appends the next page to the component's rows; the render updates data-has-more, data-page and data-loaded, which the behaviour reads, so the x-data expression never changes and Alpine never starts again.
  • Alpine: listen for infinite-scroll-load { page, done(result), fail(message), isCurrent() } on the root or an ancestor, append the rows, then call done(hasMore) or done({ hasMore, count }). A late answer after destroy or a newer load is ignored.
  • States (data-state): idle, loading (aria-busy on the root, the control aria-disabled with a spinner and Loading more…), error (the error row shows, the control reads Retry and is described by the error; automatic loading waits for Retry), done (the control hides, the end message shows).
  • One request at a time: the sentinel and the control are ignored while a page loads, and nothing loads once hasMore is false.
  • Chained loads: two frames after a page settles, a one-shot observer measures the sentinel again; while it is still within the root margin the next page loads without a scroll, so short pages fill a tall screen. At most chainLimit loads chain per burst; a burst ends when the sentinel leaves the margin, the control is pressed, or the next scroll after the limit. An empty page (count 0), hasMore false or an error stop the chain; after an error only Retry loads.
  • The control stays the same element while it loads and after an error, so keyboard focus stays on it. When the last page arrives while focus was in the control, focus moves to the end message (tabindex=-1) instead of falling to the body.
  • No layout shift: the footer keeps a 48 px minimum height while its faces take turns, and new rows append above it.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Navigation and orientation

Orient users and move between destinations.

Use when

  • Use to orient users and help them move across pages, sections, or commands.
  • A long feed or inbox (notifications, activity, search results) where people read on and rarely jump to a page number: rows append when the end of the list comes near.
  • A list inside its own scroll container (a sidebar list, a scroll-area) that should fetch more rows as it scrolls, with Livewire appending the rows or plain Alpine fetching them.
  • A plain Load more button that also works without JavaScript: set auto to false and href to the next page.

Avoid when

  • Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
  • People need to reach a known position, share a page link or see how many pages there are: use pagination.
  • A table that sorts, filters and pages on the server with a page size control: use data-table or livewire-data-table.
  • The whole list is small enough to render at once: render it and skip the extra requests, or use show-more to fold a short tail.

Use instead

  • Visible links and local navigation

Anti-patterns

  • Hiding primary wayfinding in novelty interactions
Anatomy
infinite-scroll infinite-scroll-sentinel infinite-scroll-footer infinite-scroll-error infinite-scroll-spinner infinite-scroll-label infinite-scroll-end infinite-scroll-status
Theming hooks
button (ghost or outline variant) error row: text-destructive end message: text-muted-foreground, focus ring tokens

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
managed
  • The control is a real <a href> or <button>, reachable and usable by keyboard and assistive technology whether or not the automatic loading runs; it is the no-JavaScript fallback too.
  • A polite role=status region announces "Loaded 20 more", "Loaded more. All loaded" or the error once per load; the sentinel is aria-hidden and never announced.
  • While loading the root is aria-busy and the control aria-disabled (not disabled, which would drop focus). The error row is linked to the Retry control through aria-describedby only while it shows.
  • The spinner is decorative and stops under prefers-reduced-motion (motion-safe:animate-spin); the loading text carries the meaning.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="infinite-scroll-{{ $record->id }}">
    {{-- Alpine mode: the list renders the rows, infinite-scroll sits after it.
         When its sentinel comes within 400px of the viewport it dispatches
         `infinite-scroll-load`; the host appends the next page and answers with
         done({ hasMore, count }). This preview answers from a fake server after
         400 ms. The Load more button works by keyboard at any time. --}}
    <div
        x-data="{
            rows: [],
            total: 36,
            timers: [],
            page(from, size) {
                const events = [@js(__('Invoice paid')), @js(__('Deploy finished')), @js(__('Comment added')), @js(__('Member invited'))];
                return Array.from({ length: size }, (_, index) => ({ id: from + index, title: events[(from + index) % events.length] + ' #' + (from + index) }));
            },
            init() { this.rows = this.page(1, 12) },
            load(detail) {
                this.timers.push(setTimeout(() => {
                    const next = this.page(this.rows.length + 1, Math.min(8, this.total - this.rows.length));
                    this.rows.push(...next);
                    detail.done({ hasMore: this.rows.length < this.total, count: next.length });
                }, 400));
            },
            destroy() { this.timers.forEach(clearTimeout) },
        }"
        class="w-full max-w-xl"
    >
        <ul role="list" aria-label="{{ __('Activity') }}" class="divide-y divide-border rounded-lg border border-border bg-background text-sm">
            <template x-for="row in rows" :key="row.id">
                <li class="flex min-h-10 items-center px-3 py-2" x-text="row.title"></li>
            </template>
        </ul>
        <x-ui.infinite-scroll :page="1" x-on:infinite-scroll-load="load($event.detail)" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/infinite-scroll.blade.php Blade
{{--
    Infinite Scroll: loads the next page of a list when the end of the list
    comes near, with a real "Load more" control that always works.

    Put it straight after the rows it extends (after an issue-list, an
    item-group, a <ul>): the list renders the rows, this renders the
    sentinel, the loading and error states, the button, the end message and
    a polite live region after them.

    Without JavaScript the control is a plain link to `href` (the next page)
    or a button. With JavaScript a click, or the sentinel entering the
    viewport (or the `root` scroll container) with `root-margin` to spare,
    loads in place: Livewire through `method` ($wire.<method>()), anything
    else through the bubbling `infinite-scroll-load` event with done() and
    fail(). One request runs at a time and nothing loads once `has-more` is
    false. When a short page leaves the sentinel near, the next page loads
    without a scroll, up to `chain-limit` loads in a row.

    The changing state reaches the behaviour through data attributes
    (data-has-more, data-page, data-loaded), never the x-data expression, so
    a Livewire render that appends rows never starts it again.
--}}
@props([
    // False once the last page is shown: no more loads, the end message shows.
    'hasMore' => true,
    // Livewire: the component method that appends the next page.
    'method' => null,
    // The next page URL: the control is a link to it, so it works without JavaScript.
    'href' => null,
    // Load when the sentinel comes near. False: only the button loads.
    'auto' => true,
    // A CSS selector of the scroll container (closest ancestor first). Null: the viewport.
    'root' => null,
    // How early to load, as an IntersectionObserver root margin (px or %).
    'rootMargin' => '0px 0px 400px 0px',
    // Automatic loads in a row while the sentinel stays near (short pages on a tall screen).
    'chainLimit' => 5,
    // The last page shown; the load event asks for page + 1.
    'page' => 1,
    // How many rows show now, so the live region can say "Loaded 20 more".
    'loaded' => null,
    // ghost (a quiet row button) | outline
    'variant' => 'ghost',
    'label' => null,
    'loadingText' => null,
    'errorText' => null,
    'retryLabel' => null,
    'endText' => null,
    // False keeps the end message for screen readers only.
    'showEnd' => true,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['ghost', 'outline'], true) ? $variant : 'ghost';

    $hasMore = filter_var($hasMore, FILTER_VALIDATE_BOOLEAN);
    $showEnd = filter_var($showEnd, FILTER_VALIDATE_BOOLEAN);
    $page = max(0, (int) $page);
    $loaded = is_numeric($loaded) ? max(0, (int) $loaded) : null;
    $rootMargin = is_string($rootMargin) && preg_match('/^-?\d+(\.\d+)?(px|%)(\s+-?\d+(\.\d+)?(px|%)){0,3}$/', trim($rootMargin)) === 1
        ? trim($rootMargin)
        : '0px 0px 400px 0px';

    $label = filled($label) ? (string) $label : __('Load more');
    $loadingText = filled($loadingText) ? (string) $loadingText : __('Loading more…');
    $errorText = filled($errorText) ? (string) $errorText : __('Could not load more.');
    $retryLabel = filled($retryLabel) ? (string) $retryLabel : __('Retry');
    $endText = filled($endText) ? (string) $endText : __('All loaded');
    $baseId = 'infinite-scroll-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(6));

    $config = [
        'method' => filled($method) ? (string) $method : null,
        'auto' => filter_var($auto, FILTER_VALIDATE_BOOLEAN),
        'root' => filled($root) ? (string) $root : null,
        'rootMargin' => $rootMargin,
        'chainLimit' => max(0, (int) $chainLimit),
        'texts' => [
            'error' => $errorText,
            'loadedOne' => __('Loaded 1 more'),
            'loadedMany' => __('Loaded :count more'),
            'loadedSome' => __('Loaded more'),
            'end' => $endText,
        ],
    ];
@endphp

<div
    data-slot="infinite-scroll"
    data-state="{{ $hasMore ? 'idle' : 'done' }}"
    data-has-more="{{ $hasMore ? 'true' : 'false' }}"
    data-page="{{ $page }}"
    @if ($loaded !== null) data-loaded="{{ $loaded }}" @endif
    x-data="uiInfiniteScroll({{ \Illuminate\Support\Js::from($config) }})"
    {{ $attributes->merge(['class' => 'flex min-w-0 flex-col']) }}
>
    {{-- Observed, never announced: when it nears the viewport the next page loads. --}}
    <div data-slot="infinite-scroll-sentinel" aria-hidden="true" class="h-px w-full"></div>

    {{-- One fixed-height footer: the button, its loading and retry faces and
         the end message take turns in it, so nothing below the rows jumps. --}}
    <div data-slot="infinite-scroll-footer" class="flex min-h-12 min-w-0 flex-wrap items-center justify-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
        <p id="{{ $baseId }}-error" data-slot="infinite-scroll-error" class="min-w-0 text-center text-destructive" hidden>{{ $errorText }}</p>

        <x-ui.button
            :href="filled($href) ? $href : null"
            :variant="$variant"
            size="sm"
            data-infinite-scroll-more
            :hidden="! $hasMore"
        >
            <svg data-slot="infinite-scroll-spinner" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="me-1 size-4 motion-safe:animate-spin" hidden>
                <path d="M8 2.5a5.5 5.5 0 1 1-5.5 5.5" stroke-linecap="round" />
            </svg>
            <span data-slot="infinite-scroll-label" data-face="idle">{{ $label }}</span>
            <span data-slot="infinite-scroll-label" data-face="loading" hidden>{{ $loadingText }}</span>
            <span data-slot="infinite-scroll-label" data-face="error" hidden>{{ $retryLabel }}</span>
        </x-ui.button>

        <p data-slot="infinite-scroll-end" tabindex="-1" @class([
            'min-w-0 rounded-sm text-center text-muted-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring',
            'sr-only' => ! $showEnd,
        ]) @if ($hasMore) hidden @endif>{{ $endText }}</p>
    </div>

    <p data-slot="infinite-scroll-status" role="status" aria-live="polite" class="sr-only"></p>
</div>
resources/js/ui/infinite-scroll.js JS
/**
 * Infinite Scroll behaviour.
 *
 * Loads the next page when the sentinel after a list nears the viewport (or
 * the `root` scroll container) and when the "Load more" control is pressed.
 * State lives in closure variables and `infSync()` writes the DOM from it,
 * touching an attribute only when its value differs. A MutationObserver runs
 * it again after any change inside the root, so a Livewire morph (which puts
 * the server's attributes back) converges on the same state in one pass.
 *
 * The server state arrives through data attributes on the root:
 *   data-has-more  "true" | "false"   a changed value always wins
 *   data-page      the last page shown
 *   data-loaded    the number of rows shown (for "Loaded N more")
 *
 * Loading, one request at a time:
 *   - `method` set and a Livewire $wire in scope: await $wire.call(method).
 *     A returned { hasMore, count } (or a boolean hasMore) is used like done().
 *     A rejected call (a failed request, a validation error) is fail().
 *   - otherwise: dispatch the bubbling `infinite-scroll-load` event with
 *     { page, done(result), fail(message), isCurrent() }. result is a boolean
 *     hasMore, or { hasMore, count }; nothing keeps the data attributes.
 *
 * Focus stays put: the control stays the same element while it loads and
 * after an error (it turns into Retry). When the last page arrives while the
 * control had focus, focus moves to the end message instead of the body.
 *
 * Chained loads: an IntersectionObserver reports only changes, so when a
 * short page leaves the sentinel inside the root margin (a tall screen) no
 * new entry comes. After each automatic load settles (two frames after the
 * answer, so the new rows are laid out) the sentinel is measured again with a
 * one-shot observer; if it is still near, the next page loads without a
 * scroll. At most `chainLimit` (default 5) loads chain this way per burst; a
 * burst ends when the sentinel leaves the margin or the button is pressed.
 * After the limit the button or the next scroll loads the next page. An empty page, has-more false or an error stop the
 * chain.
 *
 * An error pauses the automatic loading until Retry succeeds. Nothing loads
 * automatically without IntersectionObserver; the button still works.
 *
 * Note: this file is plain JS served by the registry, no Blade syntax.
 */
const SENTINEL = '[data-slot="infinite-scroll-sentinel"]';
// The control is a button primitive (data-slot="button"), so it is found by its own mark.
const MORE = '[data-infinite-scroll-more]';
const ERROR = '[data-slot="infinite-scroll-error"]';
const END = '[data-slot="infinite-scroll-end"]';
const STATUS = '[data-slot="infinite-scroll-status"]';
const SPINNER = '[data-slot="infinite-scroll-spinner"]';
const FACE = '[data-slot="infinite-scroll-label"]';

function setAttr(el, name, value) {
    if (!el) return;
    if (value === null) {
        if (el.hasAttribute(name)) el.removeAttribute(name);
    } else if (el.getAttribute(name) !== value) {
        el.setAttribute(name, value);
    }
}

// The attribute, not the property: an <svg> has no `hidden` property.
function setHidden(el, hidden) {
    if (el && el.hasAttribute('hidden') !== hidden) {
        if (hidden) el.setAttribute('hidden', '');
        else el.removeAttribute('hidden');
    }
}

function readInt(value) {
    const number = Number.parseInt(value ?? '', 10);

    return Number.isFinite(number) ? number : null;
}

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiInfiniteScroll', (config = {}) => {
        // Kept outside the reactive object: DOM nodes and observers must not
        // be wrapped in Alpine proxies.
        let root = null;
        let mutations = null;
        let intersections = null;
        let sentinel = null;
        let scheduled = false;
        let frame = 0;
        let state = 'idle';
        let more = true;
        let page = 0;
        let sequence = 0;
        let errorMessage = '';
        let seen = { hasMore: null, page: null };
        let onClick = null;
        // Automatic loads in a row that no scroll started (see the header).
        let chained = 0;
        let recheck = null;
        let onScroll = null;
        const chainLimit = Number.isFinite(config.chainLimit) ? Math.max(0, config.chainLimit) : 5;
        const observerOptions = (scrollRoot) => ({ root: scrollRoot, rootMargin: config.rootMargin || '0px 0px 400px 0px' });
        const texts = config.texts || {};

        return {
            init() {
                root = this.$el;
                this.infAdopt(true);
                state = more ? 'idle' : 'done';

                mutations = new MutationObserver(() => this.infSchedule());
                mutations.observe(root, {
                    subtree: true,
                    childList: true,
                    attributes: true,
                    attributeFilter: ['hidden', 'aria-disabled', 'aria-busy', 'aria-describedby', 'data-state', 'data-has-more', 'data-page', 'data-loaded'],
                });

                if (config.auto && typeof window.IntersectionObserver === 'function') {
                    intersections = new IntersectionObserver((entries) => {
                        const entry = entries[entries.length - 1];
                        if (!entry) return;
                        // The sentinel left the margin: the next approach is a new burst.
                        if (!entry.isIntersecting) {
                            chained = 0;
                            return;
                        }
                        this.infLoad(false);
                    }, observerOptions(this.infScrollRoot()));
                }

                onClick = (event) => {
                    const button = event.target instanceof Element ? event.target.closest(MORE) : null;
                    if (!button || !root?.contains(button)) return;
                    // Loading in place replaces the no-JavaScript link to the next page.
                    event.preventDefault();
                    chained = 0;
                    this.infLoad(true);
                };
                root.addEventListener('click', onClick);
                this.infSync();
            },

            destroy() {
                this.infCancelRecheck();
                mutations?.disconnect();
                intersections?.disconnect();
                cancelAnimationFrame(frame);
                if (onClick) root?.removeEventListener('click', onClick);
                onClick = null;
                mutations = null;
                intersections = null;
                sentinel = null;
                root = null;
                // A request still running settles into nothing.
                sequence++;
            },

            /** The scroll container: the closest match first, then any match; null is the viewport. */
            infScrollRoot() {
                if (!config.root) return null;
                try {
                    return root.closest(config.root) || document.querySelector(config.root);
                } catch {
                    return null;
                }
            },

            /** Takes a server value that changed since the last look (a render). */
            infAdopt(first = false) {
                const hasMore = root.dataset.hasMore ?? 'true';
                if (first || hasMore !== seen.hasMore) {
                    more = hasMore !== 'false';
                    if (!more && state !== 'loading') state = 'done';
                    if (more && state === 'done') state = 'idle';
                }
                const serverPage = readInt(root.dataset.page);
                if (first || root.dataset.page !== seen.page) {
                    if (serverPage !== null) page = serverPage;
                }
                seen = { hasMore, page: root.dataset.page ?? null };
            },

            infSchedule() {
                if (scheduled) return;
                scheduled = true;
                queueMicrotask(() => {
                    scheduled = false;
                    this.infSync();
                });
            },

            infSync() {
                if (!root || !root.isConnected) return;
                this.infAdopt();

                const button = root.querySelector(MORE);
                const error = root.querySelector(ERROR);
                const loading = state === 'loading';
                const failed = state === 'error';

                setAttr(root, 'data-state', state);
                setAttr(root, 'aria-busy', loading ? 'true' : null);
                setHidden(button, !more);
                setAttr(button, 'aria-disabled', loading ? 'true' : null);
                setAttr(button, 'aria-describedby', failed && error?.id ? error.id : null);
                setHidden(root.querySelector(SPINNER), !loading);
                for (const face of root.querySelectorAll(FACE)) {
                    setHidden(face, face.dataset.face !== (loading ? 'loading' : failed ? 'error' : 'idle'));
                }
                if (error) {
                    setHidden(error, !failed);
                    if (failed && errorMessage && error.textContent !== errorMessage) error.textContent = errorMessage;
                }
                setHidden(root.querySelector(END), more);

                // Watch the sentinel while there is more to load and no error.
                const next = root.querySelector(SENTINEL);
                const watch = intersections && more && !failed ? next : null;
                if (sentinel !== watch) {
                    if (sentinel) intersections.unobserve(sentinel);
                    sentinel = watch;
                    if (sentinel) intersections.observe(sentinel);
                }
            },


            /** After the chain limit: the next scroll of the root starts a new burst. */
            infAwaitScroll() {
                if (onScroll) return;
                const target = this.infScrollRoot() || window;
                onScroll = () => {
                    target.removeEventListener('scroll', onScroll);
                    onScroll = null;
                    chained = 0;
                    this.infRecheck();
                };
                onScroll.target = target;
                target.addEventListener('scroll', onScroll, { passive: true });
            },

            infCancelRecheck() {
                if (onScroll) {
                    onScroll.target.removeEventListener('scroll', onScroll);
                    onScroll = null;
                }
                if (!recheck) return;
                cancelAnimationFrame(recheck.frame);
                recheck.observer?.disconnect();
                recheck = null;
            },

            /**
             * After a load settles: wait two frames for the layout, then ask a
             * one-shot observer whether the sentinel is still within the margin.
             */
            infRecheck() {
                this.infCancelRecheck();
                if (!intersections || typeof window.IntersectionObserver !== 'function') return;
                const check = { frame: 0, observer: null };
                recheck = check;
                check.frame = requestAnimationFrame(() => {
                    check.frame = requestAnimationFrame(() => {
                        if (recheck !== check || !root || !sentinel || state !== 'idle' || !more) return;
                        check.observer = new IntersectionObserver((entries) => {
                            check.observer.disconnect();
                            if (recheck !== check) return;
                            recheck = null;
                            const near = entries.some((entry) => entry.isIntersecting);
                            if (!near) {
                                chained = 0;
                                return;
                            }
                            if (chained >= chainLimit) {
                                this.infAwaitScroll();
                                return;
                            }
                            chained++;
                            this.infLoad(false);
                        }, observerOptions(this.infScrollRoot()));
                        check.observer.observe(sentinel);
                    });
                });
            },

            infAnnounce(message) {
                const status = root?.querySelector(STATUS);
                if (!status) return;
                // Clear first, so the same words twice are announced twice.
                status.textContent = '';
                cancelAnimationFrame(frame);
                frame = requestAnimationFrame(() => {
                    status.textContent = message;
                });
            },

            infLoad(fromButton) {
                if (!root || state === 'loading' || !more) return;
                // An error waits for Retry; the sentinel does not retry by itself.
                if (state === 'error' && !fromButton) return;

                const current = ++sequence;
                const hadFocus = root.contains(document.activeElement);
                const loadedBefore = readInt(root.dataset.loaded);
                const pageBefore = root.dataset.page ?? null;
                state = 'loading';
                errorMessage = '';
                this.infSync();

                const done = (result) => this.infSettle(current, result, { hadFocus, loadedBefore, pageBefore });
                const fail = (message) => this.infFail(current, message);
                const wire = config.method ? this.infWire() : null;

                if (wire) {
                    let call;
                    try {
                        call = typeof wire.call === 'function' ? wire.call(config.method) : wire[config.method]();
                    } catch (error) {
                        call = Promise.reject(error);
                    }
                    Promise.resolve(call).then(
                        // Wait one frame so the render that came with the answer is in the page.
                        (result) => requestAnimationFrame(() => done(result)),
                        () => fail(),
                    );

                    return;
                }

                root.dispatchEvent(new CustomEvent('infinite-scroll-load', {
                    bubbles: true,
                    detail: {
                        page: page + 1,
                        done,
                        fail,
                        isCurrent: () => current === sequence,
                    },
                }));
            },

            infWire() {
                try {
                    return this.$wire || null;
                } catch {
                    return null;
                }
            },

            infSettle(current, result, { hadFocus, loadedBefore, pageBefore }) {
                if (current !== sequence || !root) return;
                this.infAdopt();

                let count = null;
                if (typeof result === 'boolean') {
                    more = result;
                } else if (result && typeof result === 'object') {
                    if (typeof result.hasMore === 'boolean') more = result.hasMore;
                    if (Number.isFinite(result.count)) count = result.count;
                }
                const loadedAfter = readInt(root.dataset.loaded);
                if (count === null && loadedBefore !== null && loadedAfter !== null) count = loadedAfter - loadedBefore;
                // A render that moved data-page already counted the page (infAdopt took it).
                if ((root.dataset.page ?? null) === pageBefore) page += 1;

                state = more ? 'idle' : 'done';
                this.infSync();

                const loadedText = count === null || count < 0
                    ? texts.loadedSome || 'Loaded more'
                    : count === 1
                        ? texts.loadedOne || 'Loaded 1 more'
                        : (texts.loadedMany || 'Loaded :count more').replace(':count', String(count));
                this.infAnnounce(more ? loadedText : `${loadedText}. ${texts.end || 'All loaded'}`);

                if (!more) {
                    // The control is gone: keep focus inside, on the end message.
                    const active = document.activeElement;
                    if (hadFocus && (!active || active === document.body || !root.contains(active) || active.closest('[hidden]'))) {
                        root.querySelector(END)?.focus({ preventScroll: true });
                    }

                    return;
                }

                // Rows that did not fill the viewport: look again, since the
                // observer only reports changes. An empty page stops the chain.
                if (sentinel && intersections && count !== 0) this.infRecheck();
            },

            infFail(current, message) {
                if (current !== sequence || !root) return;
                this.infCancelRecheck();
                state = 'error';
                errorMessage = typeof message === 'string' && message !== '' ? message : texts.error || '';
                this.infSync();
                this.infAnnounce(errorMessage);
            },
        };
    });
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.0
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0