Load content automatically as users scroll — no buttons or pagination needed. This demo fetches posts from a public API in batches, shows a loading state while each page arrives, and stops once all posts have been loaded.
Demo Config
huxInfiniteScroll({ scrollerId: 'posts', useContainerRoot: true }) Alpine.js Infinite Scroll
huxInfiniteScroll wraps the native IntersectionObserver API to watch a loader element and fire a load-more event whenever it enters the observed area. The component manages debouncing via isLoading, handles cleanup on teardown, and exposes disableScroll() for when there are no more pages. Data fetching is handled entirely by the consumer.
huxInfiniteScroll requires a valid x-ref loader element and a browser environment with IntersectionObserver. The component does not fetch data or manage a list — it only signals when the next page should load.
API
huxInfiniteScroll(config)
Returns an Alpine data object with:
isLoading: booleanisDisabled: booleandisableScroll(): voidenableScroll(): void
Internal helper methods are private implementation details and are not part of the supported API contract.
Event Detail
The load-more event detail object includes:
scrollerId: string | nullmarkComplete(hasMore?: boolean): void— call when the fetch completes; passfalseto stop the observermarkFailed(): void— call when the fetch fails; resetsisLoadingwithout disabling
Options
scrollerId: string(optional) Scopes the event name tohux-infinite-scroll:{scrollerId}:load-more. Without it, the event ishux-infinite-scroll:load-more.loaderRef: string(default:'infiniteScrollLoader') Reads the loader element fromthis.$refs[loaderRef].useContainerRoot: boolean(default:false) Whentrue, the observer root is set tothis.$el(the scrollable container). Use this whenhuxInfiniteScrollis placed on the scrollable element itself. Defaults to the viewport.intersectionThreshold: number(default:0.1) Passed to the internalIntersectionObserver.rootMargin: string(default:'0px') Passed to the internalIntersectionObserver.startsDisabled: boolean(default:false) Starts the component in a disabled state without observing the loader element.loadTimeout: number(default:5000) Milliseconds beforeisLoadingis auto-reset if neithermarkCompletenormarkFailedhas been called. Set to0to disable the timeout.
Quick Start
Minimal (viewport scroll)
<div
x-data="{ items: [] }"
x-on:hux-infinite-scroll:load-more="
fetch(`/api/items?page=${++page}`)
.then(r => r.json())
.then(data => { items.push(...data); $event.detail.markComplete(data.length > 0) })
.catch(() => $event.detail.markFailed())
"
>
<ul>
<template x-for="item in items" x-bind:key="item.id">
<li x-text="item.title"></li>
</template>
</ul>
<div x-data="huxInfiniteScroll()">
<div x-ref="infiniteScrollLoader" class="h-px" aria-hidden="true"></div>
<div x-show="isLoading" aria-live="polite">Loading…</div>
</div>
</div>Container scroll
Use useContainerRoot: true when huxInfiniteScroll is on the scrollable container itself:
<div
x-data="{ items: [], page: 0 }"
x-on:hux-infinite-scroll:list:load-more.window="loadMore($event.detail)"
>
<div
x-data="huxInfiniteScroll({ scrollerId: 'list', useContainerRoot: true })"
class="max-h-96 overflow-y-auto"
>
<template x-for="item in items" x-bind:key="item.id">
<div x-text="item.title"></div>
</template>
<div x-ref="infiniteScrollLoader" class="h-px" aria-hidden="true"></div>
<div x-show="isLoading" aria-live="polite">Loading…</div>
<div x-show="isDisabled">All items loaded.</div>
</div>
</div>Common Usage Patterns
Handle Errors
Call detail.markFailed() on failure to clear isLoading without disabling the observer. The user can scroll again to retry.
async function loadMore(detail) {
try {
const res = await fetch(`/api/items?page=${++page}`)
const data = await res.json()
items.push(...data)
detail.markComplete(data.length > 0)
} catch {
detail.markFailed()
}
}Disable and Re-enable Programmatically
<button type="button" x-on:click="$refs.scroller.__x.$data.enableScroll()">
Load more
</button>
<div
x-ref="scroller"
x-data="huxInfiniteScroll({ scrollerId: 'list', startsDisabled: true })"
>
...
</div>Or call disableScroll() / enableScroll() directly inside the same component scope.
Tune Observer Sensitivity
Pre-load content before the loader element fully enters the viewport:
huxInfiniteScroll({
rootMargin: '0px 0px 200px 0px',
intersectionThreshold: 0,
})Subscribe Globally
Use .window to listen for the event on the window object from any element:
<div x-on:hux-infinite-scroll:feed:load-more.window="loadMore($event.detail)">
...
</div>Behavior Contract
- On initialization, the component looks up the loader element from
$refs[loaderRef]inside$nextTick. - If
useContainerRootistrue, the observer usesthis.$elas theroot; otherwise the root is the viewport (null). - The observer fires immediately with the loader element’s initial intersection state. If the loader element is already visible, a
load-moreevent fires on initialization — this is the expected mechanism for the first page load. isLoadingis set totruebefore dispatching the event and reset bydetail.markComplete()ordetail.markFailed(). Intersection callbacks are ignored whileisLoadingistrue.detail.markComplete(false)callsdisableScroll(), which unobserves the loader element and setsisDisabledtotrue.detail.markComplete(true)ordetail.markComplete()(default) clearsisLoadingonly.enableScroll()re-observes the loader element only when currently disabled.- On teardown, the observer is fully disconnected.
Error Handling
- If
loaderRefdoes not resolve to anx-ref, the component logs[huxInfiniteScroll] Missing x-ref target for loaderRef: ${this.loaderTemplateRefName}and exits initialization without throwing. - If the consumer does not call
detail.markComplete()ordetail.markFailed()withinloadTimeoutmilliseconds,isLoadingis auto-reset tofalseand the component logs[huxInfiniteScroll] Load timed out — markComplete or markFailed was not called. SetloadTimeout: 0to disable the timeout and manageisLoadingmanually.
Accessibility Notes
- Add
aria-live="polite"to the loading indicator so assistive technology announces when loading begins. - Keep the loader element visually hidden with
h-pxorsr-onlyand mark itaria-hidden="true"to avoid it being picked up by screen readers as list content. - Infinite scroll removes natural pagination landmarks. Consider supplementing with a visible “Load more” button for users who prefer explicit control or have reduced-motion preferences.
- Avoid placing interactive elements immediately after the loader element — they may receive focus before content loads.
Notes
- Event payload:
{ scrollerId: string | null, markComplete: (hasMore?: boolean) => void, markFailed: () => void }. hux-infinite-scroll:load-morefires when noscrollerIdis set;hux-infinite-scroll:{scrollerId}:load-morefires when one is configured.- The component itself holds no items array. It is intentionally agnostic to how data is fetched or rendered.