Skip to content
HyperUX Experimental
Demo

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 })
Loading more posts…
All posts loaded.

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:

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:

Options

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&hellip;</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&hellip;</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

Error Handling

Accessibility Notes

Notes