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.
Preview
Could not load more.
All loaded
{{-- 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>
Installation
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:
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.
-
resources/views/components/ui/infinite-scroll.blade.php -
resources/js/ui/infinite-scroll.js
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.
# 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
{{-- 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>
{{-- 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: 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
{{-- 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="' + detail.page + '"]');
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: 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
{{-- 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
{{-- 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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
{{--
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>
/**
* 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