Components
Infinite Scroll
Loads more content when a sentinel scrolls into view, with a load-more fallback.
php artisan blatui:add infinite-scroll
Item
Loading more…
You’ve reached the end.
{{--
A scrollable box that loads more items as you scroll. The component dispatches a
'load-more' event when its sentinel nears the bottom; this demo listens for it and
appends 5 more items from a JS array (with a short simulated delay so the spinner +
aria-busy are visible), capping at 30 so the "end" state shows. The Load more button
triggers the exact same path, so keyboard / no-JS users are covered too.
Real apps replace the demo's setTimeout with a Livewire call or fetch():
@load-more="fetch('/items?page=' + page)
.then(r => r.json())
.then(rows => { items.push(...rows.data); page++;
$event.target.dispatchEvent(new CustomEvent('load-more-done',
{ detail: { done: !rows.hasMore } })); })"
--}}
<div
x-data="{
items: Array.from({ length: 10 }, (_, i) => i + 1),
max: 30,
loadDemo(e) {
// Simulate a network round-trip so the loading state is visible.
setTimeout(() => {
const start = this.items.length;
const add = Math.min(5, this.max - start);
for (let i = 1; i <= add; i++) this.items.push(start + i);
const done = this.items.length >= this.max;
e.target.dispatchEvent(new CustomEvent('load-more-done', { detail: { done } }));
}, 600);
},
}"
class="w-full max-w-md"
>
<div class="h-72 overflow-y-auto rounded-lg border bg-card p-3">
<x-ui.infinite-scroll :threshold="120" x-on:load-more="loadDemo($event)">
<template x-for="n in items" :key="n">
<div class="flex items-center gap-3 rounded-md border bg-background px-3 py-2.5 text-sm">
<span class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground" x-text="n"></span>
<span class="text-foreground">Item <span x-text="n"></span></span>
</div>
</template>
</x-ui.infinite-scroll>
</div>
</div>
Filtered
Item
Loading more…
You’ve reached the end.
{{--
A filter applied in the browser over what is loaded. Only one row in six is "Rare", so with
that filter a batch adds a row or two and the end of the list stays in view. After each
batch the component checks again and asks for the next one, until the box is full or the
data runs out, instead of stopping at the Load more button (a scroll that cannot happen).
With Livewire, `more()` is a #[Renderless] method that returns the rows for an offset:
x-on:load-more="$wire.more(rows.length).then((page) => {
rows.push(...page.rows); page.hasMore ? $event.detail.loaded() : $event.detail.done();
})"
--}}
<div
x-data="{
rows: [],
filter: 'all',
total: 120,
get shown() { return this.filter === 'all' ? this.rows : this.rows.filter((r) => r.kind === this.filter); },
more(e) {
setTimeout(() => {
const start = this.rows.length;
for (let i = start + 1; i <= Math.min(start + 12, this.total); i++) {
this.rows.push({ id: i, kind: i % 6 === 0 ? 'rare' : 'common' });
}
this.rows.length >= this.total ? e.detail.done() : e.detail.loaded();
}, 150);
},
}"
class="w-full max-w-md space-y-3"
>
<div class="flex gap-2" role="group" aria-label="Filter">
<x-ui.button size="sm" variant="outline" class="aria-pressed:bg-accent" x-bind:aria-pressed="filter === 'all'" x-on:click="filter = 'all'">All</x-ui.button>
<x-ui.button size="sm" variant="outline" class="aria-pressed:bg-accent" x-bind:aria-pressed="filter === 'rare'" x-on:click="filter = 'rare'">Rare</x-ui.button>
</div>
<div class="h-72 overflow-y-auto rounded-lg border bg-card p-3">
<x-ui.infinite-scroll :threshold="120" x-on:load-more="more($event)">
<template x-for="row in shown" :key="row.id">
<div data-row class="flex items-center justify-between rounded-md border bg-background px-3 py-2.5 text-sm">
<span>Item <span x-text="row.id"></span></span>
<x-ui.badge variant="secondary" x-text="row.kind"></x-ui.badge>
</div>
</template>
</x-ui.infinite-scroll>
</div>
</div>
API Reference
Props, slots and exposed methods for
<x-ui.infinite-scroll>.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
threshold |
int
|
200
|
Distance in pixels before the sentinel reaches the scroll edge at which loadMore() fires. Mapped to the IntersectionObserver rootMargin so loading begins a little ahead of the actual edge. |
Slots
| Slot | Description |
|---|---|
default |
Your initial items. The component appends a loading indicator, a "Load more" button (keyboard / no-JS fallback) and an invisible sentinel after the slot content. |
Methods
Available on the component's Alpine scope — call them from markup in the slot
(e.g. @click="…").
| Method | Description |
|---|---|
loadMore() |
Sets the loading state and dispatches a cancelable "load-more" event. Listen for it (Livewire / fetch) to append rows, then call detail.loaded() / detail.done() or dispatch "load-more-done" on the root. No-op while already loading or finished. |
loaded() |
Clears the loading state after a batch of items has been appended, then checks the sentinel again: if it is still in view (a short batch, or rows a browser-side filter hides), the next batch is asked for straight away rather than waiting for a scroll that cannot happen (since 1.39). |
finish() |
Marks the list complete: hides the sentinel and Load more button and shows the end-of-list message. |